本文记录DIN: Deep Interest Network for Click-Through Rate Prediction的学习笔记。DIN将attention机制考虑进了CTR的预估任务中,通过设计一个局部激活单元从用户的历史行为中自适应的学习用户的兴趣。
学习过程中尤其注意不同模型的改进方向,改进原因,以及主要的缺点。
文章目录
论文及代码地址
论文地址:https://arxiv.org/abs/1706.06978
代码地址:TensorFlow:https://github.com/zhougr1993/DeepInterestNetwork
规范之后的代码,包含DIN,以及后续改进的DIEN的代码实现:
Tensorflow: https://github.com/mouna99/dien
论文细节
1. motivation
大多数DL用于推荐的模型采取相似的embedding+MLP的范式,他们通常将大规模的稀疏特征映射成为低维的embedding向量,然后按group转化成定长的向量,最后concat在一起输入到MLP学习特征间的非线性关系。但是在映射成定长向量的时候一般没有考虑到用户以前点击过的item/ad,这对于embedding+MLP学习造成了一定的瓶颈问题。为了满足用户兴趣的丰富度,特征维度应该尽可能的扩大,但是这样的话会增加模型的参数以及增加过拟合的风险。那在长度有限且固定的embedding向量的前提下,有没有什么方法可以同时满足用户兴趣的diversity呢?DIN将用户的历史ad采用attention的机制考虑进embedding的生成部分,很大程度的缓解了这一问题。
2. method
2.1 特征表示
在CTR预测任务中,数据常常表现为多组的类别形式,对某一组的第i个特征可表示为 t i ∈ R K i t_i \in R^{K_i} ti∈RKi, K i K_i Ki 表示第i个特征的特征维度。 t i [ j ] t_i[j] ti[j]表示第i个特征的第j个元素并且 t i [ j ] ∈ { 0 , 1 } t_i[j] \in \{0, 1\} ti[j]∈{0,1}。记 ∑ j = 1 K i t i [ j ] = k \sum_{j=1}^{K_i}t_i[j]=k ∑j=1Kiti[j]=k,如果 k = 1 k=1 k=1,则特征的编码形式为one-hot 编码,如果 k > 1 k \gt 1 k>1,那么特征的编码形式为multi-hot 编码。那么网络的输入可以表示成 x = [ t 1 T , t 2 T , . . . , t M T ] T x=[t_1^{T}, t_2^T,...,t_M^T]^T x=[t1T,t2T,...,tMT]T,其中 M M M表示特征的组数,比如性别,点击时间,点击的行为序列,点击的商品,商品类别等等。
2.2 基础模型 embedding&MLP
大部分主流的推荐网络结构采用Embedding+MLP的范式,如下图:
主要包括几个部分:Embedding layer, pooling layer, concat layer, 最后是MLP
在Embedding layer中,分为两种情况:
如果输入的
t
i
t_i
ti是one-hot的向量且
t
i
[
j
]
=
1
t_i[j]=1
ti[j]=1,那么
t
i
t_i
ti的embedding向量记为:
e
i
=
w
j
i
e_i=w_j^i
ei=wji;
如果输入的
t
i
t_i
ti是multi-hot的向量,并且有
t
i
[
j
]
=
1
t_i[j]=1
ti[j]=1对于所有的
j
∈
{
i
1
,
i
2
,
.
.
.
,
i
k
}
j \in \{i_1,i_2,...,i_k\}
j∈{i1,i2,...,ik}成立,那么
t
i
t_i
ti的embedding向量可以记为
{
e
i
1
,
.
.
.
,
e
i
k
}
=
{
w
i
1
i
,
.
.
.
,
w
i
k
i
}
\{e_{i_1}, ...,e_{i_k}\}=\{w_{i_1}^i,...,w_{i_k}^i\}
{ei1,...,eik}={wi1i,...,wiki}
在pooling layer中,由于不同group对应的Embedding向量的长度不一定是相同的,所以采用一个pooling layer将所有的embedding向量统一到一个长度: e i = p o o l i n g ( e i 1 , . . . , e i k ) e_i=pooling(e_{i_1},...,e_{i_k}) ei=pooling(ei1,...,eik)。常用的pooling方式包括sum pooling和average pooling。所有的embedding向量和pooling操作都是在特征组内进行,最后把原始稀疏的特征映射到多个定长的表示向量中,这些向量concat到一起作为MLP的输入。
在MLP中,一般有多个全连接层组成。最后的优化目标如下:
L
=
−
1
N
∑
(
x
,
y
)
∈
S
(
y
l
o
g
p
(
x
)
+
(
1
−
y
)
l
o
g
(
1
−
p
(
x
)
)
)
L=- \frac{1}{N}\sum_{(x,y)\in S}(ylogp(x)+(1-y)log(1-p(x)))
L=−N1(x,y)∈S∑(ylogp(x)+(1−y)log(1−p(x)))
其中
S
S
S表示训练集,
y
∈
{
0
,
1
}
y\in\{0,1\}
y∈{0,1}表示当前这个item是否被点击,
p
(
x
)
p(x)
p(x)为网络的softmax输出,表示点击的概率。
2.3 DIN的具体结构
之前提到由于embedding之后特征向量的长度有限,不一定能完全建模用户兴趣的diversity。DIN观察到用户的历史行为与展示的广告的点击量有明显的关系,于是考虑把用户行为的信息作为attention机制建模在embedding向量,对于下图输入的N个商品,不同用户的权重应该是不同的,利用attention机制,也就是文中所提到的local activation unit可以自适应学习每个goods的权重。
如下图所示,记用户点击的行为序列的表示向量为
v
U
v_U
vU,候选的广告为
A
A
A,
v
A
v_A
vA为
A
A
A的embedding向量,有:
v
U
(
A
)
=
f
(
v
A
,
e
1
,
.
.
.
,
e
H
)
=
∑
j
=
1
H
a
(
e
j
,
v
A
)
e
j
=
∑
j
=
1
H
w
j
e
j
v_U(A)=f(v_A, e_1,...,e_H)=\sum_{j=1}^{H} a(e_j,v_A)e_j = \sum_{j=1}^{H}w_je_j
vU(A)=f(vA,e1,...,eH)=j=1∑Ha(ej,vA)ej=j=1∑Hwjej
注意这里的
H
H
H表示用户行为embedding之后的向量长度,不是原始的点击的行为长度,同理
e
1
,
e
2
,
.
.
.
e
H
e_1,e_2,...e_H
e1,e2,...eH也表示用户行为embedding之后的向量。
a
(
.
)
a(.)
a(.)表示一个MLP,用来学习用户行为的权重。
学习权重的时候采用机器翻译(NMT)任务中的attention机制,即quiry, key, value机制,但是作者做了一定的改变,比如这里的key和value均表示下图中的
U
s
e
r
b
e
h
a
v
i
o
r
s
User\ behaviors
User behaviors,quiry表示
C
a
n
d
i
d
a
t
e
A
d
Candidate\ Ad
Candidate Ad,attention机制中的
D
h
D_h
Dh表示embedding的长度
H
H
H。
具体而言:
- 参考右边的小图,输入attention unit包括 U s e r b e h a v i o r s User\ behaviors User behaviors和 C a n d i d a t e A d Candidate\ Ad Candidate Ad两部分,两者首先做特征交叉,利用 k e y s keys keys表示 U s e r b e h a v i o r s User\ behaviors User behaviors, 利用 q u e r i e s queries queries表示 C a n d i d a t e A d Candidate\ Ad Candidate Ad,作者在实现的时候采用了 q u e r i e s − k e y s queries - keys queries−keys, q u e r i e s ∗ k e y s queries * keys queries∗keys两种交叉方式,之后将 q u e r i e s − k e y s queries - keys queries−keys, q u e r i e s ∗ k e y s queries * keys queries∗keys, q u e r i e s queries queries, k e y s keys keys四种特征concat在一起,送入MLP
- 参考attention机制里面的scaling,对MLP的结果output进行缩放: o u t p u t = o u t p u t / H output=output/\sqrt H output=output/H
- 归一化: o u t p u t = s o f t m a x ( o u t p o u t ) output=softmax(outpout) output=softmax(outpout)
- 对用户行为进行加权: o u t p u t = o u t p u t ∗ k e y s output=output*keys output=output∗keys
2.4 训练过程中的优化
以上主要的部分讲完了,但是作者在训练过程中还做了一些优化。
2.4.1 Mini-batch Aware Regularization
在下图中,如果不用正则化,可以看到trainging的loss在第一个epoch之后下降的很厉害,于此同时test loss在第一个epoch之后上升很快。但是由于输入的特征是大规模的稀疏特征,直接利用
L
2
L2
L2正则不太现实。
所以作者在正则化这里做了一定的优化。具体的:
L
2
(
W
)
=
∑
j
=
1
K
∑
m
=
1
B
∑
(
x
,
y
)
∈
B
m
I
(
x
j
≠
0
)
n
j
∣
∣
w
j
∣
∣
2
2
L_2(W)=\sum_{j=1}^{K}\sum_{m=1}^{B}\sum_{(x,y)\in B_{m}}\frac{I(x_j \ne 0)}{n_j}||w_j||_2^2
L2(W)=∑j=1K∑m=1B∑(x,y)∈BmnjI(xj=0)∣∣wj∣∣22求解的时间复杂度为
O
(
K
B
∣
B
m
∣
)
O(KB|B_m|)
O(KB∣Bm∣),其中
K
K
K为embedding向量的长度,
B
B
B为batch数目,
∣
B
m
∣
|B_m|
∣Bm∣为一个batch内的数据数目
在这里记 a m j = m a x ( x , y ) ∈ B m I ( x j ≠ 0 ) a_{mj}=max_{(x,y)\in B_m}I(x_j \ne 0) amj=max(x,y)∈BmI(xj=0),即首先对一个batch的所有数据的第 j j j维embedding预处理,因为 x x x为大规模的one-hot类型的特征, x x x的绝大部分都是0,所以这里直接利用 x x x的第 j j j维特征的最大值近似代替之前 x x x的值。这样 x x x可能会从原来的one-hot类型的向量转化为multi-hot类型的向量。但是优化之后的正则表达式为: L 2 ( W ) ≈ ∑ j = 1 K ∑ m = 1 B a m j n j ∣ ∣ w j ∣ ∣ 2 2 L_2(W) \ \approx\sum_{j=1}^K\sum_{m=1}^B\frac{a_{mj}}{n_j}||w_j||_2^2 L2(W) ≈∑j=1K∑m=1Bnjamj∣∣wj∣∣22,优化之后的时间复杂度为: O ( K B ) O(KB) O(KB),降低了原始问题的求解复杂度。
在反向传播时,利用链式法则可以对权重更新:
w
j
=
w
j
−
η
[
1
∣
B
m
∣
∑
(
x
,
y
)
∈
B
m
∂
L
(
p
(
x
)
,
y
)
∂
w
j
+
λ
a
m
j
n
j
w
j
]
w_j=w_j-\eta[\frac{1}{|B_m|}\sum_{(x,y)\in B_m}\frac{\partial L(p(x),y) }{\partial w_j}+\lambda\frac{a_{mj}}{n_j}w_j]
wj=wj−η[∣Bm∣1∑(x,y)∈Bm∂wj∂L(p(x),y)+λnjamjwj]
2.4.2 Data Adaptive Activation Function
这部分主要改变了激活函数,以前的PReLU没有考虑到网络的每一层之后的数据分布的变化,所以作者提出了Dice这一激活函数,可以根据数据的分布自适应的调整激活单元的位置。
PReLU的具体形式:
f
(
x
)
=
{
s
i
f
s
>
0
α
s
i
f
s
≤
0
f(x)=\left\{ \begin{aligned} s \ if \ s \gt 0 \\ \alpha s \ if \ s \le 0 \end{aligned} \right.
f(x)={s if s>0αs if s≤0
Dice激活函数的具体形式:
f
(
x
)
=
p
(
s
)
⋅
s
+
(
1
−
p
(
s
)
)
⋅
α
s
f(x)=p(s)\cdot s+(1-p(s))\cdot \alpha s
f(x)=p(s)⋅s+(1−p(s))⋅αs,其中
p
(
s
)
=
1
1
+
e
x
p
(
−
s
−
E
(
s
)
V
a
r
(
s
)
+
ϵ
)
p(s)=\frac{1}{1+exp(-\frac{s-E(s)}{\sqrt{Var(s)+\epsilon}})}
p(s)=1+exp(−Var(s)+ϵs−E(s))1,
E
(
s
)
E(s)
E(s)和
V
a
r
(
s
)
Var(s)
Var(s)为每个layer之后数据的均值和方差。
3. 源码理解
本部分参考DIN的tensorfow源码:https://github.com/zhougr1993/DeepInterestNetwork
3.1 数据集负样本的生成
参考din/build_dataset.py
训练集:包含train_set.append((reviewerID, hist, pos_list[i], 1)),其中reviewerID为用户的id, hist为点击的时序序列,pos_list[i]为下一个将会点击的序列,1表示label,表示将会点击;负样本的生成,作者对于每个点击的样本从所有商品里面未点击的样本中采样。
测试集:test_set.append((reviewerID, hist, label)) ,其中reviewerID为用户的id, hist为点击的时序序列,label为当前序列未来点击的正负样本的组合:(pos_list[i], neg_list[i])
for reviewerID, hist in reviews_df.groupby('reviewerID'): # 生成训练集和测试集
pos_list = hist['asin'].tolist()
# print('reviewerID:{} hist:{}'.format(reviewerID, hist))
def gen_neg():
neg = pos_list[0]
while neg in pos_list:
neg = random.randint(0, item_count - 1)
return neg
neg_list = [gen_neg() for i in range(len(pos_list))] # 这里生成negtive 样本是什么意思,这样pos list里面的每个样本都会对应一个负的样本
for i in range(1, len(pos_list)): # 学习训练集和验证集的生成方式
hist = pos_list[:i] #
print('i: {}, hist: {}'.format(i,hist))
if i != len(pos_list) - 1:
train_set.append((reviewerID, hist, pos_list[i], 1))
train_set.append((reviewerID, hist, neg_list[i], 0)) # 这部分表示没有被点击的item
else:
label = (pos_list[i], neg_list[i])
test_set.append((reviewerID, hist, label)) # 这里的label为什么是二元组呢 #test_set[0]:(114981, [24265, 8283, 43319, 45475], (56743, 31087))
break
# 注意:他这里并没有划分验证集
random.shuffle(train_set)
random.shuffle(test_set)
3.2 DIN模型的具体内容
这部分参考:din/model.py
首先是模型的初始化,user_count(用户的数目), item_count(商品的数目), cate_count(商品的类别数目), cate_list(商品的类别), predict_batch_size(batch的大小), predict_ads_num(预测的广告的数目)
在模型的变量这里(比如self.u),可参考下图的注释:
class Model(object):
def __init__(self, user_count, item_count, cate_count, cate_list, predict_batch_size, predict_ads_num):
# loss, _ = sess.run([self.loss, self.train_op], feed_dict={
# self.u: uij[0], reviewer id
# self.i: uij[1], the last item
# self.y: uij[2], label
# self.hist_i: uij[3], 点击的行为序列,不满足sl的数值会被补零填充
# self.sl: uij[4], 每个行为序列的长度
# self.lr: l, 学习率
# })
self.u = tf.placeholder(tf.int32, [None, ]) # [B] #定义形参 用户的embedding
self.i = tf.placeholder(tf.int32, [None, ]) # [B] 下一个将要点击正样本的item的embedding
self.j = tf.placeholder(tf.int32, [None, ]) # [B] 下一个将要点击负样本的item的embedding
self.y = tf.placeholder(tf.float32, [None, ]) # [B] label
self.hist_i = tf.placeholder(tf.int32, [None, None]) # [B, T] 点击的行为序列,已经padding 0
self.sl = tf.placeholder(tf.int32, [None, ]) # [B] 每条行为序列的长度,不含padding
self.lr = tf.placeholder(tf.float64, []) #学习率
正样本(i_emb),负样本(j_emb),行为序列(h_emb)的embedding构造:
注意在训练的时后在计算图中并不需要计算j_emb,在测试的时候需要计算j_emb,用来计算GAUC等指标
hidden_units = 128
user_emb_w = tf.get_variable("user_emb_w", [user_count, hidden_units]) # 变量的名称和维度
item_emb_w = tf.get_variable("item_emb_w", [item_count, hidden_units // 2]) # 将item_count长度的向量 映射成为64维的
# 怎么设置初始值呢
item_b = tf.get_variable("item_b", [item_count],
initializer=tf.constant_initializer(0.0))
cate_emb_w = tf.get_variable("cate_emb_w", [cate_count, hidden_units // 2])
cate_list = tf.convert_to_tensor(cate_list, dtype=tf.int64)
ic = tf.gather(cate_list, self.i) # 选出最后一个正负样本对应的类别 ic
i_emb = tf.concat(values=[
tf.nn.embedding_lookup(item_emb_w, self.i), # self.i list
tf.nn.embedding_lookup(cate_emb_w, ic), # ic list
], axis=1)
i_b = tf.gather(item_b, self.i)
jc = tf.gather(cate_list, self.j) # self.j 表示什么意思呢 这部分代码未使用
j_emb = tf.concat([
tf.nn.embedding_lookup(item_emb_w, self.j),
tf.nn.embedding_lookup(cate_emb_w, jc),
], axis=1)
j_b = tf.gather(item_b, self.j)
# print('self.hist_i :{}'.format(self.hist_i))
hc = tf.gather(cate_list, self.hist_i) # 行为序列
h_emb = tf.concat([
tf.nn.embedding_lookup(item_emb_w, self.hist_i),
tf.nn.embedding_lookup(cate_emb_w, hc),
], axis=2) # 分成两部分 前面一部分为 行为序列item的weight 后面一部分为行为序列item类别对应的 wieght 两部分concat在一起
# 这里的self.sl 应该不止batch数量吧
attention机制的具体实现:
hist_i = attention(i_emb, h_emb, self.sl) #sl表示每条行为序列的长度
hist_i = tf.layers.batch_normalization(inputs=hist_i) # 这里默认是false
hist_i = tf.reshape(hist_i, [-1, hidden_units], name='hist_bn')
hist_i = tf.layers.dense(hist_i, hidden_units, name='hist_fcn') # 全连接层
u_emb_i = hist_i
hist_j = attention(j_emb, h_emb, self.sl)
# -- attention end ---
# hist_j = tf.layers.batch_normalization(inputs = hist_j)
hist_j = tf.layers.batch_normalization(inputs=hist_j, reuse=True)
hist_j = tf.reshape(hist_j, [-1, hidden_units], name='hist_bn')
hist_j = tf.layers.dense(hist_j, hidden_units, name='hist_fcn', reuse=True)
u_emb_j = hist_j
这里的 q u e r i e s queries queries值的是 C a n d i d a t e a d Candidate \ ad Candidate ad, k e y s keys keys指的是行为点击序列, k e y s _ l e n g t h keys\_length keys_length指的是有效行为序列(不含padding)的长度。具体理解可参考2.3.
def attention(queries, keys, keys_length):
'''
queries: [B, H] batch数量,hidden层
keys: [B, T, H] batch 数量, 行为序列的条数, hidd层
keys_length: [B]
self.sl :[None]
self.sl :Tensor("Placeholder_5:0", shape=(?,), dtype=int32)
h_emb :[None, None, 128]
i_emb :[None, 128]
'''
queries_hidden_units = queries.get_shape().as_list()[-1]
queries = tf.tile(queries, [1, tf.shape(keys)[1]])
queries = tf.reshape(queries, [-1, tf.shape(keys)[1], queries_hidden_units])
din_all = tf.concat([queries, keys, queries - keys, queries * keys], axis=-1) # out product这部分包含两个vector的减法和乘法
d_layer_1_all = tf.layers.dense(din_all, 80, activation=tf.nn.sigmoid, name='f1_att', reuse=tf.AUTO_REUSE)
d_layer_2_all = tf.layers.dense(d_layer_1_all, 40, activation=tf.nn.sigmoid, name='f2_att', reuse=tf.AUTO_REUSE)
d_layer_3_all = tf.layers.dense(d_layer_2_all, 1, activation=None, name='f3_att', reuse=tf.AUTO_REUSE)
d_layer_3_all = tf.reshape(d_layer_3_all, [-1, 1, tf.shape(keys)[1]])
outputs = d_layer_3_all
# Mask
# mask的作用?
key_masks = tf.sequence_mask(keys_length, tf.shape(keys)[1]) # [B, T]
key_masks = tf.expand_dims(key_masks, 1) # [B, 1, T]
paddings = tf.ones_like(outputs) * (-2 ** 32 + 1)
outputs = tf.where(key_masks, outputs, paddings) # [B, 1, T]
# 返回值是对应元素,condition中元素为True的元素替换为x中的元素,为False的元素替换为y中对应元素
# Scale
outputs = outputs / (keys.get_shape().as_list()[-1] ** 0.5)
# Activation
outputs = tf.nn.softmax(outputs) # [B, 1, T]
# Weighted sum
outputs = tf.matmul(outputs, keys) # [B, 1, H]
return outputs
MLP结构,在attention机制之后:参考2.3中DIN的模型结构图好理解一些。
# -- fcn begin -------
din_i = tf.concat([u_emb_i, i_emb, u_emb_i * i_emb], axis=-1) # 这里也用到了特征的交叉
din_i = tf.layers.batch_normalization(inputs=din_i, name='b1')
d_layer_1_i = tf.layers.dense(din_i, 80, activation=tf.nn.sigmoid, name='f1')
# if u want try dice change sigmoid to None and add dice layer like following two lines. You can also find model_dice.py in this folder.
# d_layer_1_i = tf.layers.dense(din_i, 80, activation=None, name='f1')
# d_layer_1_i = dice(d_layer_1_i, name='dice_1_i')
d_layer_2_i = tf.layers.dense(d_layer_1_i, 40, activation=tf.nn.sigmoid, name='f2')
# d_layer_2_i = tf.layers.dense(d_layer_1_i, 40, activation=None, name='f2')
# d_layer_2_i = dice(d_layer_2_i, name='dice_2_i')
d_layer_3_i = tf.layers.dense(d_layer_2_i, 1, activation=None, name='f3')
din_j = tf.concat([u_emb_j, j_emb, u_emb_j * j_emb], axis=-1)
din_j = tf.layers.batch_normalization(inputs=din_j, name='b1', reuse=True)
d_layer_1_j = tf.layers.dense(din_j, 80, activation=tf.nn.sigmoid, name='f1', reuse=True)
# d_layer_1_j = tf.layers.dense(din_j, 80, activation=None, name='f1', reuse=True)
# d_layer_1_j = dice(d_layer_1_j, name='dice_1_j')
d_layer_2_j = tf.layers.dense(d_layer_1_j, 40, activation=tf.nn.sigmoid, name='f2', reuse=True)
# d_layer_2_j = tf.layers.dense(d_layer_1_j, 40, activation=None, name='f2', reuse=True)
# d_layer_2_j = dice(d_layer_2_j, name='dice_2_j')
d_layer_3_j = tf.layers.dense(d_layer_2_j, 1, activation=None, name='f3', reuse=True)
d_layer_3_i = tf.reshape(d_layer_3_i, [-1])
d_layer_3_j = tf.reshape(d_layer_3_j, [-1])
x = i_b - j_b + d_layer_3_i - d_layer_3_j # [B]
self.logits = i_b + d_layer_3_i
训练过程中的loss函数:
self.loss = tf.reduce_mean(
tf.nn.sigmoid_cross_entropy_with_logits(
logits=self.logits,
labels=self.y)
)
参考文献:
[1] https://arxiv.org/abs/1706.06978
[2] https://github.com/zhougr1993/DeepInterestNetwork