在学习今天的模型之前,可以先回顾一下前面学到的一些模型。事实上,之前学习的一些模型都有一些共同点,首先就是都将特征分为稠密和稀疏两部分分别处理,对于稀疏特征一般都会进行embedding。随后稠密的特征直接传入深度部分进行学习,而稀疏的部分得到embedding后会与稠密部分的进行连接,然后传入DNN部分进行特征交叉。最后,DNN的输出会经过sigmoid激活后得到最终输出。这次我们学习的模型DIN同样是遵循这个套路的,但是在这个模板的基础上,DIN增加了注意力机制。使得模型更加关注对于用户有比较大影响力的特征。
DIN原理
前面学习的模型在处理特征的时候并不考虑不同特征对于不同用户的影响,这些模型往往把所有特征一视同仁的防到DNN中进行训练交叉,无法体现用户广泛的兴趣。但是在用户实际的点击过程中,用户历史点击往往能提供很大的参考价值,然而这些历史行为商品也并不是每一个都对用户的下一次点击有比较大的影响,因此就需要使用注意力机制对于这些历史商品信息进行权重设计。在DIN中,这个起到注意力机制作用的单元被称之为local activation unit。看一下模型的结构图:
左边是基础模型,也就是我们之前学习的通常的处理方法。右边是DIN的处理方式,可以看到,DIN在基础模型上增加了一个 activation unit,也就是前面说的注意力机制。下面看一下这个单元是如何进行注意力分配的:
v
U
(
A
)
=
f
(
v
A
,
e
1
,
e
2
,
…
,
e
H
)
=
∑
j
=
1
H
a
(
e
j
,
v
A
)
e
j
=
∑
j
=
1
H
w
j
e
j
\boldsymbol{v}_{U}(A)=f\left(\boldsymbol{v}_{A}, \boldsymbol{e}_{1}, \boldsymbol{e}_{2}, \ldots, \boldsymbol{e}_{H}\right)=\sum_{j=1}^{H} a\left(\boldsymbol{e}_{j}, \boldsymbol{v}_{A}\right) \boldsymbol{e}_{j}=\sum_{j=1}^{H} \boldsymbol{w}_{j} \boldsymbol{e}_{j}
vU(A)=f(vA,e1,e2,…,eH)=j=1∑Ha(ej,vA)ej=j=1∑Hwjej
其中
{
v
A
,
e
1
,
e
2
,
…
,
e
H
}
\{\boldsymbol{v}_{A}, \boldsymbol{e}_{1}, \boldsymbol{e}_{2}, \ldots, \boldsymbol{e}_{H}\}
{vA,e1,e2,…,eH}是用户的历史行为特征的embedding,
v
A
v_A
vA是候选广告A的embedding数量,
a
(
e
j
,
v
A
)
=
w
j
a(e_j, v_A)=w_j
a(ej,vA)=wj表示权重或者历史行为商品与当前广告
A
A
A的相关性程度。
a
(
⋅
)
a(\cdot)
a(⋅)表示的上面那个前馈神经网络,也就是那个所谓的注意力机制。
如何理解这个公式呢,比如一个用户的历史行为中有90%是服饰相关的,而只有10%是和电子产品相关的,那么如果有两个广告商品,一个是T恤,一个是IPHONE,那么T恤会有更高的权重。
模型实现学习
接下来看一下模型是如何实现的,与前面的模型不同的是,DIN模型需要处理的不仅包括稠密特征和稀疏特征,还包括可变长的稀疏特征,这个就是我们前面说的用户历史商品序列了。对于这三种特征,我们需要分别进行处理。
# DIN网络搭建
def DIN(feature_columns, behavior_feature_list, behavior_seq_feature_list):
"""
这里搭建DIN网络,有了上面的各个模块,这里直接拼起来
:param feature_columns: A list. 里面的每个元素是namedtuple(元组的一种扩展类型,同时支持序号和属性名访问组件)类型,表示的是数据的特征封装版
:param behavior_feature_list: A list. 用户的候选行为列表
:param behavior_seq_feature_list: A list. 用户的历史行为列表
"""
# 构建Input层并将Input层转成列表作为模型的输入
input_layer_dict = build_input_layers(feature_columns)
input_layers = list(input_layer_dict.values())
# 筛选出特征中的sparse和Dense特征, 后面要单独处理
sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), feature_columns))
dense_feature_columns = list(filter(lambda x: isinstance(x, DenseFeat), feature_columns))
# 获取Dense Input
dnn_dense_input = []
for fc in dense_feature_columns:
dnn_dense_input.append(input_layer_dict[fc.name])
# 将所有的dense特征拼接
dnn_dense_input = concat_input_list(dnn_dense_input) # (None, dense_fea_nums)
# 构建embedding字典
embedding_layer_dict = build_embedding_layers(feature_columns, input_layer_dict)
# 离散的这些特特征embedding之后,然后拼接,然后直接作为全连接层Dense的输入,所以需要进行Flatten
dnn_sparse_embed_input = concat_embedding_list(sparse_feature_columns, input_layer_dict, embedding_layer_dict, flatten=True)
# 将所有的sparse特征embedding特征拼接
dnn_sparse_input = concat_input_list(dnn_sparse_embed_input) # (None, sparse_fea_nums*embed_dim)
# 获取当前行为特征的embedding, 这里有可能有多个行为产生了行为列表,所以需要列表将其放在一起
query_embed_list = embedding_lookup(behavior_feature_list, input_layer_dict, embedding_layer_dict)
# 获取历史行为的embedding, 这里有可能有多个行为产生了行为列表,所以需要列表将其放在一起
keys_embed_list = embedding_lookup(behavior_seq_feature_list, input_layer_dict, embedding_layer_dict)
# 使用注意力机制将历史行为的序列池化,得到用户的兴趣
dnn_seq_input_list = []
for i in range(len(keys_embed_list)):
seq_embed = AttentionPoolingLayer()([query_embed_list[i], keys_embed_list[i]]) # (None, embed_dim)
dnn_seq_input_list.append(seq_embed)
# 将多个行为序列的embedding进行拼接
dnn_seq_input = concat_input_list(dnn_seq_input_list) # (None, hist_len*embed_dim)
# 将dense特征,sparse特征, 即通过注意力机制加权的序列特征拼接起来
dnn_input = Concatenate(axis=1)([dnn_dense_input, dnn_sparse_input, dnn_seq_input]) # (None, dense_fea_num+sparse_fea_nums*embed_dim+hist_len*embed_dim)
# 获取最终的DNN的预测值
dnn_logits = get_dnn_logits(dnn_input, activation='prelu')
model = Model(inputs=input_layers, outputs=dnn_logits)
return model
下面我们一步步的来观察数据和特征是如何变化的。
这次用到的数据集是movielens数据集,先来看一下数据集长什么样子。
将数据转换为array格式。
将特征转换为nametuple方便后面进行处理
下面看一下模型内部是如何工作的。
前面的生成稠密特征的DNN输入以及稀疏特征的embedding及连接与之前学习的模型都是一样的,这里不多看了。直接看一下对于当前行为特征和历史行为特征序列是如何处理的。
首先是当前行为特征的处理。
# 获取当前的行为特征(movie)的embedding,这里有可能有多个行为产生了行为序列,所以需要使用列表将其放在一起
query_embed_list = embedding_lookup(behavior_feature_list, input_layer_dict, embedding_layer_dict)
得到的输出结果如下,生成了一个shape为(1,8)的tnesor。
与当前行为特征不同的是,历史特征序列生成的是一个(50,8)维的embedding。
接下来就是使用注意力机制将历史movie_id序列进行池化
# 使用注意力机制将历史movie_id序列进行池化
dnn_seq_input_list = []
for i in range(len(keys_embed_list)):
seq_emb = AttentionPoolingLayer()([query_embed_list[i], keys_embed_list[i]])
dnn_seq_input_list.append(seq_emb)
我们进入AttentionPoolingLayer类,看一下这个类做了什么。
class AttentionPoolingLayer(Layer):
def __init__(self, att_hidden_units=(256, 128, 64)):
super(AttentionPoolingLayer, self).__init__()
self.att_hidden_units = att_hidden_units
self.local_att = LocalActivationUnit(self.att_hidden_units)
def call(self, inputs):
# keys: B x len x emb_dim, queries: B x 1 x emb_dim
queries, keys = inputs
# 获取行为序列embedding的mask矩阵,将Embedding矩阵中的非零元素设置成True,
key_masks = tf.not_equal(keys[:,:,0], 0) # B x len
# key_masks = keys._keras_mask # tf的有些版本不能使用这个属性,2.1是可以的,2.4好像不行
# 获取行为序列中每个商品对应的注意力权重
attention_score = self.local_att([queries, keys]) # B x len
# 去除最后一个维度,方便后续理解与计算
# outputs = attention_score
# 创建一个padding的tensor, 目的是为了标记出行为序列embedding中无效的位置
paddings = tf.zeros_like(attention_score) # B x len
# outputs 表示的是padding之后的attention_score
outputs = tf.where(key_masks, attention_score, paddings) # B x len
# 将注意力分数与序列对应位置加权求和,这一步可以在
outputs = tf.expand_dims(outputs, axis=1) # B x 1 x len
# keys : B x len x emb_dim
outputs = tf.matmul(outputs, keys) # B x 1 x dim
outputs = tf.squeeze(outputs, axis=1)
return outputs
AttentionPoolingLayer输入是当前特征商品和历史特征商品的序列中的一个,然后将两者放入另一个类LocalActivationUnit中计算注意力权重。再看一下注意力权重是如何计算的:
class LocalActivationUnit(Layer):
def __init__(self, hidden_units=(256, 128, 64), activation='prelu'):
super(LocalActivationUnit, self).__init__()
self.hidden_units = hidden_units
self.linear = Dense(1)
self.dnn = [Dense(unit, activation=PReLU() if activation == 'prelu' else Dice()) for unit in hidden_units]
def call(self, inputs):
# query: B x 1 x emb_dim keys: B x len x emb_dim
query, keys = inputs
# 获取序列长度
keys_len = keys.get_shape()[1]
queries = tf.tile(query, multiples=[1, keys_len, 1]) # (None, len, emb_dim)
# 将特征进行拼接
att_input = tf.concat([queries, keys, queries - keys, queries * keys], axis=-1) # B x len x 4*emb_dim
# 将原始向量与外积结果拼接后输入到一个dnn中
att_out = att_input
for fc in self.dnn:
att_out = fc(att_out) # B x len x att_out
att_out = self.linear(att_out) # B x len x 1
att_out = tf.squeeze(att_out, -1) # B x len
return att_out
这一块暂时还有些地方没看懂,还需要再学习一下。
这一步完成之后,就得到了经过加权的历史序列特征列表,然后进行拼接。
接下来就是将得到的dnn_seq_input也就是带权重的历史序列特征和前面得到的稀疏特征和稠密特征拼接起来,输入到logits层得到最终的输出。
# 将dense特征,sparse特征,及通过注意力加权的序列特征拼接
dnn_input = Concatenate(axis=1)([dnn_dense_input, dnn_sparse_input, dnn_seq_input])
# 获取最终dnn的logits
dnn_logits = get_dnn_logits(dnn_input, activation='prelu')
后面就是构建模型并且训练了。模型训练的结果如下: