0%

推荐系统-YouTubeDNN详解

最近阅读了一篇2016年的推荐经典论文Deep Neural Network for YouTobe Recommendations, 网上也有关于这篇文章的许多讨论,特写此篇博客用以记录我对此模型的理解以及上线经历。

候选集生成

这部分和word2vec的做法非常相似,可以参考我的这篇博客,把召回过程看作是一个多分类问题,整个模型结构如下图所示

可以看到最后一层是softmax层,然后在实际求解中使用NCE方法。最终可以通过基于LSH(局部哈希敏感)算法的ANN引擎做线上的召回。和word2vec不同的地方在多了两层非线性的隐藏层,全联接形式。 整个输入向量可以分为两大类:embedding向量和连续维度的数值。其中提出的example age这个特征比较特别,它的含义是上架时间到生成样本所经过的时长。所以会含有一个英文单词age,这就是年龄的含义。 在实际inference时,我们会给一个0值或者很小的负值。这样做的目的是让新鲜的视频有更多曝光的机会。有利于召回上传时间短,但是视频内容与用户兴趣更相关的新视频。 其实加入这个特征的原因是上架越早的视频,更有机会成为热门的视频,与大多数用户相关,因此在训练时加入该特征,有利于该特征的权重较大,而在线上召回时,将该特征的权重进行打压,则有利于其他相关性特征,因此对上传时间较短的新视频的召回有一定的帮助。

排序模型

排序模型使用的weighed LR模型,具体结构如下

weighed LR有两种实现方式

  1. up-sampling方式 上采样通过增加正样本数量达到加权目的

  2. magnify weight 这种方法就是在计算损失函数是引入权重。典型的如tensorflow提供的函数weighted_cross_entropy_with_logits

我们来复习一些概念

\[\begin{equation} \begin{split} y = \frac{1}{1 + e^{-w^Tx+ b}} \\ odds = e^{w^Tx+b} = \frac{p(y=1|x)}{p(y=0|x)} \\ p = \frac{odds}{odd + 1} \end{split} \end{equation}\]

odds 表示一件事情的发生比,是该事件发生和不发生的比率。logit就是对这个比率取对数。其中\(odds = e^{w^Tx+b} = \frac{p(y=1|x)}{p(y=0|x)}\) 在weighted logistic regression中,\(odds = \frac{\sum{T_i}}{N-k}\),解释一下含义,假设总共的曝光次数为N,有观看的次数为k,我们把观看时长\(T_i\)看成权重,没有观看的曝光的权重是单位1 同样每一次曝光的观看时长的期望可以写做 \(E(T) = \frac{\sum T_i}{N}\), 这个式子也很好理解,从古典概型的角度去看。所以我们会有

\[ E(T)=\frac{\sum T_i}{N} = \frac{\frac{\sum T_i}{N-k}}{\frac{N}{N-k}} =\frac{odds}{\frac{N}{N-k}} \]

因为一般情况下k远小于N,所以\(E(T) \simeq odds\) 或者通过级数的角度理解

\[ odds = E(T)\frac{N}{N-k} = E(T)\frac{1}{1-p} \simeq E(T)*(1+p) \simeq E(T) \]

  1. tensorflow 小试牛刀 tensorflow是google推出的深度学习框架,实现了底层线性代数运算方法。我们使用tensorflow实现了召回和排序模型,一些核心代码如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class sort_model(tf.Module):
def __init__(self, name=None):

super().__init__(name=name)
with tf.name_scope("sort"):
self.weight = tf.compat.v1.placeholder(tf.float32)
self.label = tf.compat.v1.placeholder(tf.float32)
self.input_embedding = tf.compat.v1.placeholder(tf.float32)
with tf.variable_scope("sort", reuse=tf.AUTO_REUSE):
self.hidden_layer_0 = tf.get_variable('hidden_layer_0', initializer=tf.random.normal([512, 256], 0.0, 1.0), )
self.hidden_layer_1 = tf.get_variable('hidden_layer_1', initializer=tf.random.normal([256, 128], 0.0, 1.0))
self.output_layer = tf.get_variable('output_layer', initializer=tf.random.normal([128, 1], 0.0, 1.0))

def forward(self):
tmp = tf.nn.swish(
tf.matmul(
tf.nn.swish(tf.matmul(self.input_embedding, self.hidden_layer_0)), self.hidden_layer_1)) # shape T, 128

return tf.matmul(tmp, self.output_layer) #logit


def cost_func(self):
tmp = tf.nn.swish(
tf.matmul(
tf.nn.swish(tf.matmul(self.input_embedding, self.hidden_layer_0)), self.hidden_layer_1)) # shape T, 128

cost = tf.reduce_mean(
tf.squeeze(
tf.nn.weighted_cross_entropy_with_logits(labels=self.label, logits=tf.matmul(tmp, self.output_layer),
pos_weight=self.weight)))
return cost


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class recall_model(tf.Module):
def __init__(self, VOCABULARY_NUM, HIDDEN_NUM, name=None):

super().__init__(name=name)
with tf.name_scope("recall"):
self.pos = tf.compat.v1.placeholder(tf.int32)
self.input_embedding = tf.compat.v1.placeholder(tf.float32)
self.neg = tf.compat.v1.placeholder(tf.int32)
with tf.variable_scope("recall", reuse=tf.AUTO_REUSE):
self.hidden_layer_0 = tf.get_variable('hidden_layer_0', initializer=tf.random.normal([256, 512], 0.0, 1.0), )
self.hidden_layer_1 = tf.get_variable('hidden_layer_1', initializer=tf.random.normal([512, 256], 0.0, 1.0))
self.output_layer = tf.get_variable('output_layer', initializer=tf.zeros([VOCABULARY_NUM, HIDDEN_NUM]))

def forward(self):

tmp_softmax_input = tf.expand_dims(
tf.nn.swish(tf.matmul(tf.nn.swish(tf.matmul(self.input_embedding, self.hidden_layer_0)), self.hidden_layer_1)),
axis=1)

return tmp_softmax_input

def cost_func(self):
tmp_pos = tf.nn.embedding_lookup(self.output_layer, self.pos) # shape T,1,256
tmp_neg = tf.nn.embedding_lookup(self.output_layer, self.neg) # shape T,K,256
tmp_softmax_input = self.forward()

p = tf.matmul(tmp_softmax_input, tf.transpose(tmp_pos, perm=[0, 2, 1])) # shape T, 1, 1

n = tf.matmul(tmp_softmax_input, tf.transpose(tmp_neg, perm=[0, 2, 1])) # shape T, 1, k

pos_part = tf.compat.v1.reduce_sum(
tf.math.log(
tf.nn.sigmoid(
tf.clip_by_value(p, -8, 8))), axis=1) # shape T,1

neg_part = tf.compat.v1.reduce_sum(
tf.math.log(1 -
tf.nn.sigmoid(
tf.clip_by_value(n, -8, 8))), axis=1) # shape T,K

cost = - tf.reduce_mean(
tf.compat.v1.reduce_sum(pos_part, axis=1) + tf.compat.v1.reduce_sum(neg_part, axis=1))

return cost

下面我简单汇总一下使用tensorflow过程中的注意点

  1. variable_scope vs name_scope 作为一种图式计算引擎,应该使用命名空间来做变量隔离。name_scope只会决定对象属于哪个范围,并不会对对象的作用范围产生任何影响。通常name_scope配合tensorboard可视化使用 variable_scope 配合 get_variable 使用。可以共享变量

  2. 一些有用的tf函数 在使用tf时,始终要保证shape的运算准确性,有一些函数可以方便的改变tensor的shape。tf.sqeeze可以去除所有度量为1的维度,比如shape(1,4,1)变成shape(4,). tf.reduce_sum 对应axis的维度做sum操作。

  3. 模型部署 tf的模型部署(单机)并不复杂,比较绕的地方在它的配置文件,在进行模型热更新的时候,至少需要两步,第一步新模型load,第二步修改label的绑定。这样模型才会正式生效

小结

  1. youTubeDNN的召回模型需要类比word2vec,样本的生成同样考虑subsampling,优化手段也是negative sampling
  2. 排序模型是使用的weighted LR,计算出的odds 就是观看时长的期望。这样一个优化目标符合视频网站的业务要求
  3. 使用tensorflow serving作为模型的上线部署,注意其配置文件的修改顺序。