YouTubeDNN模型

Deep Neural Networks for YouTube Recommendations

YouTubeDNN模型是2016年的一篇文章,这篇文章给出了很多优化推荐系统中的工程性经验和trick,比如召回方面的"example age", "负采样","非对称消费,防止泄露",排序方面的特征工程,加权逻辑回归等, 这些东西至今也都非常的实用。

文章最后调用Deep Match的包在新闻推荐数据集上进行实验,模型结构本质上并不是很复杂(三四层的全连接网络)。

  • YouTubeDNN的召回模型理论与细节剖析
  • YouTubeDNN的排序模型理论与细节剖析
  • YouTubeDNN的deepmatch的使用方法
  • YouTubeDNN新闻推荐数据集的实验记录

1. 引言

在工业上的YouTube视频推荐系统主要面临的三大挑战:

  • Scale(规模): 视频数量非常庞大,大规模数据下需要分布式学习算法以及高效的线上服务系统,文中在召回模型线下训练,采用了负采样,线上服务的时候,采用了hash映射,然后近邻检索的方式来满足实时性的需求, 可以了解学习一下faiss包和annoy包的使用。

  • Freshness(新鲜度): 视频是动态的、 用户实时上传且实时访问, 用户一般都比较喜欢看比较新的视频,需要模型有建模新上传内容以及用户最新发生的行为能力。 为了让模型学习到用户对新视频有偏好, 策略里面加了一个"example age"。类似于"探索与利用"中的探索,是对新鲜度的把握。

  • Noise(噪声): 由于数据的稀疏和不可见的原因, 数据里面的噪声非常之多,召回上需要考虑更多实际因素,比如非对称消费特性,高活用户因素,时间因素,序列因素等, 而排序上做更加细致的特征工程, 尽量刻画出用户兴趣以及视频的特征优化训练目标,使用加权的逻辑回归等。而召回和排序模型上,都采用了深度神经网络,通过特征的相互交叉,有了更强大的建模能力, 相比于矩阵分解, 建模能力上有了很大的提升, 有助于帮助减少噪声, 使得推荐结果更加准确。

    YouTubeDNN推荐系统架构:

召回侧:

模型的输入一般是用户的点击历史, 认为这些历史能更好的代表用户的兴趣, 另外还有一些人口统计学特征,比如性别,年龄,地域等, 都可以作为召回侧模型的输入。 而最终模型的输出,就是与该用户相关的一个候选视频集合, 量级的话一般是几百。

召回侧,大致上有两大类召回方式,一类是策略规则,一类是监督模型+embedding,其中策略规则,往往和真实场景有关,比如热度,历史重定向等,不同的场景会有不同的召回方式。模型+embedding思路是一种"普适"方法,本质上是给用户或者是物品打embedding。

精排侧:

召回对于每个用户给出了几百个比较相关的候选视频, 把几百万的规模降到了几百,但是利用的特征信息有限,并不能很好的刻画用户和视频特点,所以, 在精排侧,利用了更多的用户,视频特征,选出几个或者十几个推荐给用户。 主要的做法一般有三个:特征工程, 模型设计以及训练方法。

精排侧,从ctr预估到多目标, 从人工特征工程到特征工程自动化。主要是两大块, CTR预估分为LR,FM、自动特征交叉的DNN家族等,多目标优化,是很多大公司的研究现状,更是未来的一大发展趋势,研究热点又可以拆分成网络结构演化以及loss设计优化等。

2. YouTubeDNN的召回模型细节剖析

召回模型的目的是在大量YouTube视频中检索出数百个和用户相关的视频来。可以看成一个多分类的问题,即用户在某一个时刻点击了某个视频, 可以建模成输入一个用户向量, 从海量视频中预测出被点击的那个视频的概率。

在时刻 t t t 下,用户 U U U 在背景 C C C 下对每个视频 i i i 的观看行为,公式如下:
P ( w t = i ∣ U , C ) = e v i u ∑ j ∈ V e v j u P\left(w_{t}=i \mid U, C\right)=\frac{e^{v_{i} u}}{\sum_{j \in V} e^{v_{j} u}} P(wt=i∣U,C)=∑j∈Vevjueviu
u u u 表示用户向量,这里的 v v v 表示视频向量,两者的维度都是 N N N ,召回模型的任务,就是通过用户的历史点击和上下文特征,学习最終的用户表示向量 u u u 以及视频 i i i 的表示向量 v i v_{i} vi , 区别是 v i v_{i} vi 本身就是模型参数,而 u u u 是神经网络的输出(函数输出),是输入与模型参数的计算结果。


这个公式借鉴了word2vec的思想, e ( v i u ) e^{\left(v_{i} u\right)} e(viu) 表示的是当前用户向量 u u u 与当前视频 v i v_{i} vi 的相似程度, e e e 只是放大这个相似程度, 因为两个向量的点积运算可以衡量两个向量的相似程度 ,两个向量越相似,点积就会越大。再看分母 ∑ j ∈ V e v j u \sum_{j \in V} e^{v_{j} u} ∑j∈Vevju, 这个显然是用户向量 u u u 与所有视频 v v v 的一个相似程度求和。两者相除,代表了用户 u u u 与输出的视频 v i v_{i} vi 的相似程度,只不过归一化到了0-1之间。

召回模型结构

这个模型结构就是一个简单的DNN。

输入内容:

  • 用户侧的特征,包括用户观看的历史video序列, 用户搜索的历史tokens, 和用户的人文特征,比如地理位置, 性别,年龄。
  • 用户历史序列,历史搜索tokens这种序列性的特征: [item_id5, item_id2, item_id3, ...], 这种id特征是高维稀疏,首先通过一个embedding层,转成低维稠密的embedding特征,即历史序列里面的每个id都会对应一个embedding向量, 这样历史序列就变成了多个embedding向量的形式, 这些向量会进行融合,常见的是average pooling,即每一维求平均得到一个最终向量来表示用户的历史兴趣或搜索兴趣。
  • 用户人文特征, 这种特征处理方式就是离散型的通过label Encoder,然后embedding转成低维稠密, 连续型特征先归一化操作,然后直接输入,当然有的也通过分桶,转成离散特征,这里连续型特征除了用了x本身,还用了x的平方、开方、取log等数据, 可以加入更多非线性,增加模型表达能力。

embedding向量是通过word2vec方法计算的,每个item通过w2v方式算好embedding,直接作为输入,然后进行pooling融合。除了这种embedding方式之外,还可以通过embedding层跟上面的DNN一起训练,很多精排模型都是用这种方式。

论文里面使用了用户最近的50次观看历史,用户最近50次搜索历史token, embedding维度是256维, 采用的average pooling。 当然,这里还可以把item的类别信息也隐射到embedding, 与前面的concat起来。

用户的人文特征对新用户的推荐会比较有帮助,比如用户的地理位置, 设备, 性别,年龄等。

这里一个比较有特色的特征是example age,后面需要单独整理。

这些特征处理好了之后,拼接起来,就成了一个非常长的向量,然后经过一个三层的DNN,得到了输出, 这个输出也是向量。最后是做多分类问题,然后求损失。

这里的过程, 其实就是skip-gram过程, 不一样的是右边这个中心词向量v 是直接过了一个embedding层得到的,而左边这个用户向量u 是用户的各种特征先拼接成一个大的向量,然后过了一个DNN降维。 训练方式上,这两个也是一样的,无非就是左边的召回模型,多了几层全连接。

typescript 复制代码
模型训练好了之后,user向量和item向量:

user向量: 就是全连接的DNN网络的输出向量,没有全连接,原始的用户各个特征拼接起来的那个长向量维度可能太大,所以DNN在这里的作用一个是特征交叉,另一个还有降维的功效。

item向量: 和skip-gram一样,每个item其实是用两个embedding向量的,比如skip-gram那里就有一个作为中心词时候的embedding矩阵W和作为上下文词时候的embedding矩阵W′,一般取前面那个W作为每个词的词向量。 

这里最前面那个item向量矩阵,是通过了w2v的方式训练好了直接作为的输入,如果不事先计算好,对应的是embedding层得到的那个矩阵。 后面的item向量矩阵,就是这里得到用户向量之后,后面进行softmax之前的这个矩阵, YouTubeDNN最终是从这个矩阵里面拿item向量。

实际训练的时候,和word2vec也是一样,采用了负采样的优化方式,因为视频的数量太大,每次做多分类,最终那个概率分母上的加和就非常庞大,所以就把多分类问题转成了多个二分类的问题。不用全部的视频,而是随机选择出了一些没点的视频, 标记为0, 点了的视频标记为1, 这样就成了二分类的问题。

typescript 复制代码
负样本的选择方式:
展示数据随机选择负例
随机负例与热门打压
训练数据的选取和生成

训练样本来源于全部的YouTube观看记录,而不仅仅是被推荐的观看记录,否则对于新视频会难以被曝光,会使最终推荐结果有偏;同时系统也会采集用户从其他渠道观看的视频,从而可以快速应用到协同过滤中;

其次,是训练数据来源于用户的隐式数据, 且用户看完了的视频作为正样本, 注意这里是看完了,有一定的时长限制, 而不是仅仅曝光点击,有可能有误点的。 而负样本,是从视频库里面随机选取,或者在曝光过的里面随机选取用户没看过的作为负样本。

这里的一个经验是训练数据中对于每个用户选取相同的样本数, 保证用户在损失函数等权重, 因为这样可以减少高度活跃用户对于loss的影响。可以改进线上A/B测试的效果。

"Example Age"特征

视频有明显的生命周期,例如刚上传的视频比之后更受欢迎,也就是用户往往喜欢看最新的东西,而不管它是不是和用户相关,所以视频的流行度随着时间的分布是高度非稳态变化的(下面图中的绿色曲线)

但是我们模型训练的时候,是基于历史数据训练的(历史观看记录的平均),所以模型对播放某个视频预测值的期望会倾向于其在训练数据时间内的平均播放概率(平均热度),为了让模型学习到用户这种对新颖内容的bias, 作者引入了"example age"这个特征来捕捉视频的生命周期。

"example age"定义为Tmax-t,Tmax是训练数据中所有样本的时间最大值(比如用的数据集, 最晚时间是2021年7月的), 而t为当前样本的时间。线上预测时, 直接把example age全部设为0或一个小的负值,这样就不依赖于各个视频的上传时间了。

typescript 复制代码
现在常用的是位置上的除偏, 比如商品推荐的时候,用户往往喜欢点击最上面位置的商品或广告,,为了让模型学习到这个东西,可以把商品或者广告的位置信息做成一个feature, 训练的时候告诉模型。而线上推理的那些商品,这个feature也都用一样的。

example age这个特征,可以把某视频的热度分布信息传递给模型了,比如某个example age时间段该视频播放较多,而另外的时间段播放较少,这样模型就能发现用户的这种新颖偏好,消除热度偏见。

example age的优势:

  • 线上预测时example age是常数值, 所有item可以设置成统一的, 用户向量只需要计算一次。
  • 对不同的视频,对应的example age所在范围一致, 只依赖训练数据选取的时间跨度,便于归一化操作。
实验结果

作者这里主要验证了下DNN的结构对推荐效果的影响,对于DNN的层级,作者尝试了0~4层, 实验结果是层数越多越好, 但4层之后提升很有限, 层数越多训练越困难

从"双塔"的角度再看YouTubeDNN召回模型, 这里的DNN个结构,其实就是一个用户塔, 输入用户的特征,最终通过DNN,编码出了用户的embedding向量。

而得到用户embedding向量到后面做softmax那块,会经过一个item embedding矩阵, 其实这个矩阵也可以用一个item塔来实现, 和用户embedding计算的方式类似, 首先各个item通过一个物品塔(输入是item 特征, 输出是item embedding),也能得到每个item的embedding,然后做多分类或者是二分类等。

所以YouTube DNN召回模型本质上还是双塔结构, 只不过图里面只体现了用户塔。

DeepMatch包里面实现,用户特征和item特征分开输入的,应该就是实现了个双塔。

线上服务

YouTube采用了一种最近邻搜索的方法去完成topK推荐,这其实是工程与学术trade-off的结果, model serving过程中对几百万个候选集跑模型显然不现实, 所以通过召回模型得到用户和video的embedding之后, 用最近邻搜索的效率会快很多。

甚至不用把任何model inference的过程搬上服务器,只需要把user embedding和video embedding存到redis或者内存中就可以了。

在线上,可以根据用户兴趣Embedding,采用类似Faiss或Annoy等高效Embedding检索工具,快速找出和用户兴趣匹配的物品。

typescript 复制代码
做线上召回的时候,其实可以有两种:

item_2_item: 有了所有item的embedding,就可以进行物品与物品之间相似度计算,每个物品得到近似的K个,通过用户观看过的历史item,就能进行相似召回了,工程实现上,一般会每个item建立一个相似度倒排表

user_2_item: 将item用faiss或者annoy组织成index,然后用user embedding去查相近item

3. YouTubeDNN排序模型细节剖析

排序模型结构:

特征工程

排序模型用的特征主要分为两大类, 一类是展示(impression)相关特征, 如视频属性等,另一类是query相关属性,如user/context属性。 看上面图里面:

  • imporession video ID embedding: 当前要计算的video的embedding

  • watched video IDs: 用户观看过的最近N个视频embedding,然后求pooling

  • language embedding: 用户语言embedding和视频embedding

这三个,就是上面两大类的代表, 当然,还可以加入更多视频和用户特点的特征, 用户的年龄,性别, 职业,位置等, 视频的类别, 上传时间, 关键词等。

这种特征的处理方式很常规理论, 类别特征需要embedding, 连续特征需要归一化。 但这里面提到了两点新的也很有意思:

  1. 类别特征embedding的维度选择: 建议选择与item物料库大小的对数比例,即 embed dim ⁡ ∝ log ⁡ \operatorname{dim} \propto \log dim∝log (Vocab Size )。如果某个类别的取值特别多,可以限定一个值,长尾的值对应的表示为全 0 的向量(这个也是一个工程和学术的trade-off,低频item的embedding学不好,干脆截断它)
  2. 作者发现对连续特征的归一化处理方式很很影响训练的收敛性,作者在这里采用了一种累积分布归一化的方式,对于连续变量 x x x ,假设它的取值分布为 f f f ,通过以下公式把 x x x 归一化为 x ~ ∈ [ 0 , 1 ) : x ~ = ∫ − ∞ x d f \tilde{x} \in[0,1): \tilde{x}=\int_{-\infty}^{x} \mathrm{~d} f x~∈[0,1):x~=∫−∞x df ,就是 x ~ \tilde{x} x~ 在 x x x 整体取值中的百分比排序位置。
  3. 具体归一化时,会使用 x x x 训练数据中取值分布的四分位值揷值得到近似的 x ~ \tilde{x} x~ 。除了归一化 后的 x ~ , x ~ 2 \tilde{x} , \tilde{x}^{2} x~,x~2 和 x ~ \sqrt{\tilde{x}} x~ 也会加入到输入中。这也是处理连续特征的常见手段。可以增加非线性。

后面的两个特征更为重要。 即能描述用户历史上与待评分视频,或类似视频已有的交互行为信号的特征,例如用户与待评分视频所在频道的交互历史:用户观看了此频道的多少视频?用户最后一次观看同主题视频是什么时候?这些特征的泛发性很好,对待评分视频的预测很有帮助。

  • time since last watch: 自上次观看同channel视频的时间
  • previous imporessions: 该视频已经被曝光给该用户的次数
建模目标 - 用户的观看时长

模型优化的目标是每次展示的平均观看时间。作者认为按点击率排序会倾向于把诱惑用户点击(用户未必真感兴趣)的视频排前面,而观看时间能更好地反映出用户对视频的兴趣。

训练数据中的正样本是曝光点击的视频数据, 负样本是曝光未点击的数据, 优化目标是点击率,这是我们之前排序模型常用的目标。

训练样本的权重, paper做的时候,是所有负样本的权重都是1, 而正样本的权重是点击后的视频观看时长T i ,模型最后计算损失的时候,使用了加权交叉熵:

Weighted CE Loss = − ∑ i [ T i y i log ⁡ p i + ( 1 − y i ) log ⁡ ( 1 − p i ) ] =-\sum_{i}\left[T_{i} y_{i} \log p_{i}+\left(1-y_{i}\right) \log \left(1-p_{i}\right)\right] =−∑i[Tiyilogpi+(1−yi)log(1−pi)]

这里的 T i T_{i} Ti 是观看时长。由于逻辑回归的表达式:
p i = 1 1 + e − f ( x i ) p_{i}=\frac{1}{1+e^{-f\left(\mathbf{x}_{i}\right)}} pi=1+e−f(xi)1

线上使用的时候,选取了值 e f ( x i ) e^{f\left(x_{i}\right)} ef(xi) 最大的那些视频进行推荐。

那么为什么优化这样的加权Loss,就是近似优化平均观看时长呢?

因为sigmoid函数是由对数线性模型推导过来的:
log ⁡ p 1 − p = θ T X ⇒ p 1 − p = e θ T X ⇒ p ( 1 + e θ T X ) = e θ T X ⇒ p = 1 1 + e − θ T X \begin{aligned} &\log \frac{p}{1-p}=\theta^{T} X \Rightarrow \frac{p}{1-p}=e^{\theta^{T} X} \Rightarrow p\left(1+e^{\theta^{T} X}\right)=e^{\theta^{T} X} \\ &\Rightarrow p=\frac{1}{1+e^{-\theta^{T} X}} \end{aligned} log1−pp=θTX⇒1−pp=eθTX⇒p(1+eθTX)=eθTX⇒p=1+e−θTX1

前面的 log ⁡ p 1 − p , \log \frac{p}{1-p} , log1−pp, 这个东西就是logit函数,而去掉log符号之后的变量叫做几率,即一件事情发生和不发生的比值。

如果每个正例都用时长进行加权,那么分子上的正例个数可以近似等于 ∑ i T i \sum_{i} T_{i} ∑iTi ,所以等式右边的化简为

p o s # n e g = ∑ i T i n − k = ∑ i T i n n n − k = E [ T ] 1 1 − p ≃ E [ T ] \frac{\# \mathrm{pos}}{\# \mathrm{neg}}=\frac{\sum_{i} T_{i}}{n-k}=\frac{\sum_{i} T_{i}}{n} \frac{n}{n-k}=E[T] \frac{1}{1-p} \simeq E[T] #neg#pos=n−k∑iTi=n∑iTin−kn=E[T]1−p1≃E[T]

由于视频推荐场景中,用户打开一个视频的概率 p p p 往往是一个很小的值,所以上面式子其实就近似为 用户观看视频的平均时长。而
p 1 − p = e W x + b \frac{p}{1-p}=e^{W x+b} 1−pp=eWx+b

所以最終
e W x + b ≃ E [ T ] e^{W x+b} \simeq E[T] eWx+b≃E[T]

采用 e W x + b e^{W x+b} eWx+b 这个形式预测就近似用户观看视频时长的期望,用该指标排序后推荐,符合YouTube推荐场景和以观看时长为优化目标。

实验结果

作者定义了一个"weighted, per-user loss":给定一个用户, 每次选定同页面展示的一对样本作为比较对象, 一个为正(曝光点击), 一个为负(曝光未点击), 权重来自正样本观看时间, 如果负样本的得分比正样本高, 就认为正样本的观看时间被误识别了, weight,per-user loss是误识别的观看时间占总观看时间的比例。

4. 基于DeepMatch包YouTubeDNN的使用方法

参考浅梦大佬写的DeepMatch包

数据集:

实验用的数据集是新闻推荐的一个数据集,总共3个文件: 用户画像,文章画像, 点击日志,用户数量100多万,6000多万次点击, 文章规模是几百,数据量也比较丰富

对数据进行了采样, 采样方法写成了一个jupyter文件。 主要包括:

  • 分块读取数据, 无法一下子读入内存

  • 对于每块数据,基于一些筛选规则进行记录的删除,比如只用了后7天的数据, 删除了一些文章不在物料池的数据, 删除不合法的点击记录(曝光时间大于文章上传时间), 删除没有历史点击的用户,删除观看时间低于3s的视频, 删除历史点击序列太短和太长的用户记录

  • 删除完之后重新保存一份新数据集,大约3个G,然后再从这里面随机采样了20000用户进行了后面实验

通过上面的一波操作, 就能跑起来了,当然可能数据比较少,最终训练的YouTubeDNN效果并不是很好。

详细看后面GitHub的: 点击日志数据集初步处理与采样.ipynb

简单数据预处理:

看了下采样后的数据,序列长度分布等,上面做了一些规整化,这里有毛病的数据不是太多,并没有太多处理, 但是用户数据里面的年龄,性别源数据是给出了多种可能, 每个可能有概率值,我这里选出了概率最大的那个,然后简单填充了缺失。

最后把能用到的用户画像和文章画像统一拼接到了点击日志数据,又保存了一份。 作为YouTubeDNN模型的使用数据, 其他模型我也打算使用这份数据了。

详见EDA与数据预处理.ipynb

YouTubeDNN召回

首先拿到采样的数据集,我们先划分下训练集和测试集:

  • 测试集: 每个用户的最后一次点击记录
  • 训练集: 每个用户除最后一次点击的所有点击记录
python 复制代码
user_click_hist_df, user_click_last_df = get_hist_and_last_click(click_df)

保证不能发生数据穿越,拿最后的测试,不能让模型看到。

接下来就是YouTubeDNN模型的召回,从构造数据集 -> 训练模型 -> 产生召回结果,我写到了一个函数里面去。

python 复制代码
def youtubednn_recall(data, topk=200, embedding_dim=8, his_seq_maxlen=50, negsample=0,
                      batch_size=64, epochs=1, verbose=1, validation_split=0.0):
    """通过YouTubeDNN模型,计算用户向量和文章向量
    param: data: 用户日志数据
    topk: 对于每个用户,召回多少篇文章
    """
    user_id_raw = data[['user_id']].drop_duplicates('user_id')
    doc_id_raw = data[['article_id']].drop_duplicates('article_id')
    
    # 类别数据编码   
    base_features = ['user_id', 'article_id', 'city', 'age', 'gender']
    feature_max_idx = {}
    for f in base_features:
        lbe = LabelEncoder()
        data[f] = lbe.fit_transform(data[f])
        feature_max_idx[f] = data[f].max() + 1
        
    # 构建用户id词典和doc的id词典,方便从用户idx找到原始的id
    user_id_enc = data[['user_id']].drop_duplicates('user_id')
    doc_id_enc = data[['article_id']].drop_duplicates('article_id')
    user_idx_2_rawid = dict(zip(user_id_enc['user_id'], user_id_raw['user_id']))
    doc_idx_2_rawid = dict(zip(doc_id_enc['article_id'], doc_id_raw['article_id']))
    
    # 保存下每篇文章的被点击数量, 方便后面高热文章的打压
    doc_clicked_count_df = data.groupby('article_id')['click'].apply(lambda x: x.count()).reset_index()
    doc_clicked_count_dict = dict(zip(doc_clicked_count_df['article_id'], doc_clicked_count_df['click']))

    train_set, test_set = gen_data_set(data, doc_clicked_count_dict, negsample, control_users=True)
    
    # 构造youtubeDNN模型的输入
    train_model_input, train_label = gen_model_input(train_set, his_seq_maxlen)
    test_model_input, test_label = gen_model_input(test_set, his_seq_maxlen)
    
    # 构建模型并完成训练
    model = train_youtube_model(train_model_input, train_label, embedding_dim, feature_max_idx, his_seq_maxlen, batch_size, epochs, verbose, validation_split)
    
    # 获得用户embedding和doc的embedding, 并进行保存
    user_embs, doc_embs = get_embeddings(model, test_model_input, user_idx_2_rawid, doc_idx_2_rawid)
    
    # 对每个用户,拿到召回结果并返回回来
    user_recall_doc_dict = get_youtube_recall_res(user_embs, doc_embs, user_idx_2_rawid, doc_idx_2_rawid, topk)
    
    return user_recall_doc_dict

主要步骤如下:

  • 用户id和文章id我们要先建立索引-原始id的字典,因为模型里面是要把id转成embedding,模型的表示形式会是{索引: embedding}的形式, 如果我们想得到原始id,必须先建立起映射来
  • 把类别特征进行label Encoder, 模型输入需要, embedding层需要,这是构建词典常规操作, 这里要记录下每个特征特征值的个数,建词典索引的时候用到,得知道词典大小
  • 保存每篇文章被点击数量, 方便后面对高热文章实施打压
  • 构建数据集
python 复制代码
rain_set, test_set = gen_data_set(data, doc_clicked_count_dict, negsample, control_users=True)

虽然我们上面有了一个训练集,但不能直接作为模型输入, 第一个原因是正样本太少,样本数量不足,需要滑动窗口,每个用户再滑动构造一些,第二个是不满足deepmatch实现的模型输入格式,所以gen_data_set这个函数,是用deepmatch YouTubeDNN的第一个范式,只不过我加了一些策略上的尝试:

python 复制代码
def gen_data_set(click_data, doc_clicked_count_dict, negsample, control_users=False):
    """构造youtubeDNN的数据集"""
    # 按照曝光时间排序
    click_data.sort_values("expo_time", inplace=True)
    item_ids = click_data['article_id'].unique()
    
    train_set, test_set = [], []
    for user_id, hist_click in tqdm(click_data.groupby('user_id')):
        # 这里按照expo_date分开,每一天用滑动窗口滑,可能相关性更高些,另外,这样序列不会太长,因为eda发现有点击1111个的
        #for expo_date, hist_click in hist_date_click.groupby('expo_date'):
        # 用户当天的点击历史id
        pos_list = hist_click['article_id'].tolist()
        user_control_flag = True
        
        if control_users:
            user_samples_cou = 0
        
        # 过长的序列截断
        if len(pos_list) > 50:
            pos_list = pos_list[-50:]

        if negsample > 0:
            neg_list = gen_neg_sample_candiate(pos_list, item_ids, doc_clicked_count_dict, negsample, methods='multinomial')
        
        # 只有1个的也截断 去掉,当然我之前做了处理,这里没有这种情况了
        if len(pos_list) < 2:
            continue
        else:
            # 序列至少是2
            for i in range(1, len(pos_list)):
                hist = pos_list[:i]
                # 这里采用打压热门item策略,降低高展item成为正样本的概率
                freq_i = doc_clicked_count_dict[pos_list[i]] / (np.sum(list(doc_clicked_count_dict.values())))
                p_posi = (np.sqrt(freq_i/0.001)+1)*(0.001/freq_i)
                
                # p_posi=0.3  表示该item_i成为正样本的概率是0.3,
                if user_control_flag and i != len(pos_list) - 1:
                    if random.random() > (1-p_posi):
                        row = [user_id, hist[::-1], pos_list[i], hist_click.iloc[0]['city'], hist_click.iloc[0]['age'], hist_click.iloc[0]['gender'], hist_click.iloc[i]['example_age'], 1, len(hist[::-1])]
                        train_set.append(row)
                        
                        for negi in range(negsample):
                            row = [user_id, hist[::-1], neg_list[i*negsample+negi], hist_click.iloc[0]['city'], hist_click.iloc[0]['age'], hist_click.iloc[0]['gender'], hist_click.iloc[i]['example_age'], 0, len(hist[::-1])]
                            train_set.append(row)
                        
                        if control_users:
                            user_samples_cou += 1
                            # 每个用户序列最长是50, 即每个用户正样本个数最多是50个, 如果每个用户训练样本数量到了30个,训练集不能加这个用户了
                            if user_samples_cou > 30:  
                                user_samples_cou = False
                
                # 整个序列加入到test_set, 注意,这里一定每个用户只有一个最长序列,相当于测试集数目等于用户个数
                elif i == len(pos_list) - 1:
                    row = [user_id, hist[::-1], pos_list[i], hist_click.iloc[0]['city'], hist_click.iloc[0]['age'], hist_click.iloc[0]['gender'], 0, 0, len(hist[::-1])]
                    test_set.append(row)
    
    
    random.shuffle(train_set)
    random.shuffle(test_set)
    
    return train_set, test_set   

代码逻辑:

首先点击数据按照时间戳排序,然后按照用户分组,对于每个用户的历史点击, 采用滑动窗口的形式,边滑动边构造样本,

注意点一:是每滑动一次生成一条正样本的时候, 要加入一定比例的负样本进去

注意点二:最后一整条序列要放到test_set里面

里面加入的一些策略,负样本候选集生成我单独写成一个函数,因为尝试了随机采样和打压热门item采样两种方式, 可以通过methods参数选择。

另外一个就是正样本里面也按照热门实现了打压, 减少高热item成为正样本概率,增加高热item成为负样本概率。 还加了一个控制用户样本数量的参数,保证每个用户生成一样多的样本数量,打压下高活用户。

构造模型输入

这个也是调包的操作,按照这个写法来:

python 复制代码
def gen_model_input(train_set, his_seq_max_len):
    """构造模型的输入"""
    # row: [user_id, hist_list, cur_doc_id, city, age, gender, label, hist_len]
    train_uid = np.array([row[0] for row in train_set])
    train_hist_seq = [row[1] for row in train_set]
    train_iid = np.array([row[2] for row in train_set])
    train_u_city = np.array([row[3] for row in train_set])
    train_u_age = np.array([row[4] for row in train_set])
    train_u_gender = np.array([row[5] for row in train_set])
    train_u_example_age = np.array([row[6] for row in train_set])
    train_label = np.array([row[7] for row in train_set])
    train_hist_len = np.array([row[8] for row in train_set])
    
    train_seq_pad = pad_sequences(train_hist_seq, maxlen=his_seq_max_len, padding='post', truncating='post', value=0)
    train_model_input = {
        "user_id": train_uid,
        "click_doc_id": train_iid,
        "hist_doc_ids": train_seq_pad,
        "hist_len": train_hist_len,
        "u_city": train_u_city,
        "u_age": train_u_age,
        "u_gender": train_u_gender, 
        "u_example_age":train_u_example_age
    }
    return train_model_input, train_label

构造数据集的时候,把每个特征加入到了二维数组里面去, 需要让模型知道每一个维度是什么特征数据。如果相加特征,首先构造数据集的时候,需要把数据加入到数组中, 然后在这个函数里面再指定新加入的特征是啥。 下面的那个词典, 是为了把数据输入和模型的Input层给对应起来,通过字典键进行标识。

训练YouTubeDNN

固定格式, 在建模型事情,要把特征封装起来,告诉模型哪些是离散特征,哪些是连续特征, 模型要为这些特征建立不同的Input层,处理方式是不一样的

python 复制代码
def train_youtube_model(train_model_input, train_label, embedding_dim, feature_max_idx, his_seq_maxlen, batch_size, epochs, verbose, validation_split):
    """构建youtubednn并完成训练"""
    # 特征封装
    user_feature_columns = [
        SparseFeat('user_id', feature_max_idx['user_id'], embedding_dim),
        VarLenSparseFeat(SparseFeat('hist_doc_ids', feature_max_idx['article_id'], embedding_dim,
                                                        embedding_name="click_doc_id"), his_seq_maxlen, 'mean', 'hist_len'),    
        
        SparseFeat('u_city', feature_max_idx['city'], embedding_dim),
        SparseFeat('u_age', feature_max_idx['age'], embedding_dim),
        SparseFeat('u_gender', feature_max_idx['gender'], embedding_dim),
        DenseFeat('u_example_age', 1,)
    ]
    doc_feature_columns = [
        SparseFeat('click_doc_id', feature_max_idx['article_id'], embedding_dim)
        # 这里后面也可以把文章的类别画像特征加入
    ]
    
    # 定义模型
    model = YoutubeDNN(user_feature_columns, doc_feature_columns, num_sampled=5, user_dnn_hidden_units=(64, embedding_dim))
    
    # 模型编译
    model.compile(optimizer="adam", loss=sampledsoftmaxloss)
    
    # 模型训练,这里可以定义验证集的比例,如果设置为0的话就是全量数据直接进行训练
    history = model.fit(train_model_input, train_label, batch_size=batch_size, epochs=epochs, verbose=verbose, validation_split=validation_split)
    
    return model

然后就是建模型,编译训练即可。模型方面有些参数,注意点在于,就是这里用户特征和item特征进行了分开, 这其实和双塔模式很像, 用户特征最后编码成用户向量, item特征最后编码成item向量。

获得用户向量和item向量

模型训练完之后,就能从模型里面拿用户向量和item向量, 这里单独写了一个函数:

python 复制代码
 获取用户embedding和文章embedding
def get_embeddings(model, test_model_input, user_idx_2_rawid, doc_idx_2_rawid, save_path='embedding/'):
    doc_model_input = {'click_doc_id':np.array(list(doc_idx_2_rawid.keys()))}
    
    user_embedding_model = Model(inputs=model.user_input, outputs=model.user_embedding)
    doc_embedding_model = Model(inputs=model.item_input, outputs=model.item_embedding)
    
    # 保存当前的item_embedding 和 user_embedding 排序的时候可能能够用到,但是需要注意保存的时候需要和原始的id对应
    user_embs = user_embedding_model.predict(test_model_input, batch_size=2 ** 12)
    doc_embs = doc_embedding_model.predict(doc_model_input, batch_size=2 ** 12)
    # embedding保存之前归一化一下
    user_embs = user_embs / np.linalg.norm(user_embs, axis=1, keepdims=True)
    doc_embs = doc_embs / np.linalg.norm(doc_embs, axis=1, keepdims=True)
    
    # 将Embedding转换成字典的形式方便查询
    raw_user_id_emb_dict = {user_idx_2_rawid[k]: \
                                v for k, v in zip(user_idx_2_rawid.keys(), user_embs)}
    raw_doc_id_emb_dict = {doc_idx_2_rawid[k]: \
                                v for k, v in zip(doc_idx_2_rawid.keys(), doc_embs)}
    # 将Embedding保存到本地
    pickle.dump(raw_user_id_emb_dict, open(save_path + 'user_youtube_emb.pkl', 'wb'))
    pickle.dump(raw_doc_id_emb_dict, open(save_path + 'doc_youtube_emb.pkl', 'wb'))
    
    # 读取
    #user_embs_dict = pickle.load(open('embedding/user_youtube_emb.pkl', 'rb'))
    #doc_embs_dict = pickle.load(open('embedding/doc_youtube_emb.pkl', 'rb'))
    return user_embs, doc_embs

获取embedding的这两行代码是固定操作, 下面做了一些归一化操作,以及把索引转成了原始id的形式。

向量最近邻检索,为每个用户召回相似item
python 复制代码
def get_youtube_recall_res(user_embs, doc_embs, user_idx_2_rawid, doc_idx_2_rawid, topk):
    """近邻检索,这里用annoy tree"""
    # 把doc_embs构建成索引树
    f = user_embs.shape[1]
    t = AnnoyIndex(f, 'angular')
    for i, v in enumerate(doc_embs):
        t.add_item(i, v)
    t.build(10)
    # 可以保存该索引树 t.save('annoy.ann')
    
    # 每个用户向量, 返回最近的TopK个item
    user_recall_items_dict = collections.defaultdict(dict)
    for i, u in enumerate(user_embs):
        recall_doc_scores = t.get_nns_by_vector(u, topk, include_distances=True)
        # recall_doc_scores是(([doc_idx], [scores])), 这里需要转成原始doc的id
        raw_doc_scores = list(recall_doc_scores)
        raw_doc_scores[0] = [doc_idx_2_rawid[i] for i in raw_doc_scores[0]]
        # 转换成实际用户id
        try:
            user_recall_items_dict[user_idx_2_rawid[i]] = dict(zip(*raw_doc_scores))
        except:
            continue
    
    # 默认是分数从小到大排的序, 这里要从大到小
    user_recall_items_dict = {k: sorted(v.items(), key=lambda x: x[1], reverse=True) for k, v in user_recall_items_dict.items()}
    
    # 保存一份
    pickle.dump(user_recall_items_dict, open('youtube_u2i_dict.pkl', 'wb'))
    
    return user_recall_items_dict

用了用户embedding和item向量,就可以通过这个函数进行检索, 主要是annoy包做近邻检索的固定格式, 检索完毕,为用户生成最相似的200个候选item。

上面就是使用YouTubeDNN做召回的整个流程。 效果如下:

字典形式:

接下来就是评估模型的效果,采用了简单的HR@N, 效果一般,结果如下:

YouTubeDNN新闻推荐数据集的实验记录

  • 负采样方式上,尝试了随机负采样和打压高热item两种方式, 实验结果显示, 带打压的效果略好一点点
  • 特征上, 尝试原论文给出的example age的方式,做一个样本的年龄特征出来
    这个年龄样本,我是用的训练集的最大时间减去曝光的时间,然后转成小时间隔算的,而测试集里面的统一用0表示, 但效果很差看好多文章说这个时间单位是个坑,不知道是小时,分钟,另外这个特征我只做了简单归一化,感觉应该需要做归一化
  • 尝试了控制用户数量,即每个用户的样本数量保持一样,效果比上面略差
  • 开始模型评估,尝试用最后一天,而不是最后一次点击的, 感觉效果不如最后一次点击作为测试集效果好

总结

召回部分:

  • 训练数据的样本来源应该是全部物料, 而不仅仅是被推荐的物料,否则对于新物料难以曝光

  • 训练数据中对于每个用户选取相同的样本数, 保证用户在损失函数等权重, 这个虽然不一定非得这么做,但考虑打压高活用户或者是高活item的影响还是必须的

  • 序列无序化: 用户的最近一次搜索与搜索之后的播放行为有很强关联,为了避免信息泄露,将搜索行为顺序打乱

  • 训练数据构造: 预测接下来播放而不是用传统cbow中的两侧预测中间的考虑是可以防止信息泄露,并且可以学习到用户的非对称视频消费模式

  • 召回模型中,类似word2vec,video 有input embedding和output embedding两组embedding,并不是共享的, input embedding论文里面是用w2v事先训练好的, 其实也可以用embedding层联合训练

  • 召回模型的用户embedding来自网络输出, 而video的embedding往往用后面output处的

  • 使用 example age 特征处理 time bias,这样线上检索时可以预先计算好用户向量

排序部分:

  • 特征工程连续特征归一化,可以按分布进行归一化
  • 连续特征重要的, 还可以人为加入非线性,平方,对数,开根号形式等
  • 能描述用户历史上与待评分视频,或类似视频已有的交互行为信号的特征很重要
  • 排序模型训练使用基于观看时长加权的交叉熵,这样产生的排序可以近似认为是基于期望观看时长进行的排序

参考:

  • 重读Youtube深度学习推荐系统论文
  • YouTube深度学习推荐系统的十大工程问题
  • 你真的读懂了Youtube DNN推荐论文吗
  • 推荐系统经典论文(二)】YouTube DNN
  • 张俊林-推荐技术发展趋势与召回模型
  • 揭开YouTube深度推荐系统模型Serving之谜
  • Deep Neural Networks for YouTube Recommendations YouTubeDNN推荐召回与排序
  • https://github.com/zhongqiangwu960812/AI-RecommenderSystem
相关推荐
დ旧言~2 小时前
【高阶数据结构】图论
算法·深度优先·广度优先·宽度优先·推荐算法
ASKED_201920 小时前
特征交叉-Deep&Cross Network学习
推荐算法
机智的小神仙儿1 天前
Query Processing——搜索与推荐系统的核心基础
人工智能·推荐算法
B站计算机毕业设计超人1 天前
计算机毕业设计SparkStreaming+Kafka新能源汽车推荐系统 汽车数据分析可视化大屏 新能源汽车推荐系统 汽车爬虫 汽车大数据 机器学习
数据仓库·爬虫·python·数据分析·kafka·数据可视化·推荐算法
慕卿扬1 天前
基于python的机器学习(三)—— 关联规则与推荐算法
python·学习·机器学习·推荐算法
B站计算机毕业设计超人5 天前
计算机毕业设计Python+大模型农产品推荐系统 农产品爬虫 农产品商城 农产品大数据 农产品数据分析可视化 PySpark Hadoop
大数据·爬虫·python·深度学习·机器学习·课程设计·推荐算法
二进制_博客5 天前
ALS 推荐算法案例演示(python)
python·算法·推荐算法
B站计算机毕业设计超人11 天前
计算机毕业设计Python+图神经网络考研院校推荐系统 考研分数线预测 考研推荐系统 考研爬虫 考研大数据 Hadoop 大数据毕设 机器学习 深度学习
爬虫·python·深度学习·机器学习·知识图谱·数据可视化·推荐算法
清风絮柳12 天前
27.旅游推荐管理系统(基于springboot和vue)
vue·毕业设计·springboot·旅游·推荐算法·前后端分离·旅游推荐系统
知来者逆13 天前
Gen-RecSys——一个通过生成和大规模语言模型发展起来的推荐系统
人工智能·gpt·语言模型·自然语言处理·llm·推荐算法·多模态