写在前面
MIND 模型 (Multi-Interest Network with Dynamic Routing), 是阿里团队 2019 年在 CIKM 上发的一篇 paper,该模型依然是用在召回阶段的一个模型,解决的痛点是之前在召回阶段的模型,比如双塔,YouTubeDNN 召回模型等,在模拟用户兴趣的时候,总是基于用户的历史点击,最后通过 pooling 的方式得到一个兴趣向量,用该向量来表示用户的兴趣,但是该篇论文的作者认为,用一个向量来表示用户的广泛兴趣未免有点太过于单一,这是作者基于天猫的实际场景出发的发现,每个用户每天与数百种产品互动, 而互动的产品往往来自于很多个类别,这就说明用户的兴趣极其广泛,用一个向量是无法表示这样广泛的兴趣的,于是乎,就自然而然的引出一个问题,有没有可能用多个向量来表示用户的多种兴趣呢?
这篇 paper 的核心是胶囊网络,该网络采用了动态路由算法能非常自然的将历史商品聚成多个集合,每个集合的历史行为进一步推断对应特定兴趣的用户表示向量。这样,对于一个特定的用户,MND 输出了多个表示向量,它们代表了用户的不同兴趣。当用户再有新的交互时,通过胶囊网络,还能实时的改变用户的兴趣表示向量,做到在召回阶段的实时个性化。那么,胶囊网络究竟是怎么做到的呢? 胶囊网络又是什么原理呢?
主要内容:
- 背景与动机
- 胶囊网络与动态路由机制
- MIND 模型的网络结构与细节剖析
- MIND 模型之简易代码复现
- 总结
背景与动机
本章是基于天猫 APP 的背景来探索十亿级别的用户个性化推荐。天猫的推荐的流程主要分为召回阶段和排序阶段。召回阶段负责检索数千个与用户兴趣相关的候选物品,之后,排序阶段预测用户与这些候选物品交互的精确概率。这篇文章做的是召回阶段的工作,来对满足用户兴趣的物品的有效检索。
作者这次的出发点是基于场景出发,在天猫的推荐场景中,作者发现用户的兴趣存在多样性。平均上,10 亿用户访问天猫,每个用户每天与数百种产品互动。交互后的物品往往属于不同的类别,说明用户兴趣的多样性。 一张图片会更加简洁直观:
因此如果能在召回阶段建立用户多兴趣模型来模拟用户的这种广泛兴趣,那么作者认为是非常有必要的,因为召回阶段的任务就是根据用户兴趣检索候选商品嘛。
那么,如何能基于用户的历史交互来学习用户的兴趣表示呢? 以往的解决方案如下:
- 协同过滤的召回方法 (itemcf 和 usercf) 是通过历史交互过的物品或隐藏因子直接表示用户兴趣, 但会遇到稀疏或计算问题
- 基于深度学习的方法用低维的 embedding 向量表示用户,比如 YoutubeDNN 召回模型,双塔模型等,都是把用户的基本信息,或者用户交互过的历史商品信息等,过一个全连接层,最后编码成一个向量,用这个向量来表示用户兴趣,但作者认为,这是多兴趣表示的瓶颈,因为需要压缩所有与用户多兴趣相关的信息到一个表示向量,所有用户多兴趣的信息进行了混合,导致这种多兴趣并无法体现,所以往往召回回来的商品并不是很准确,除非向量维度很大,但是大维度又会带来高计算。
- DIN 模型在 Embedding 的基础上加入了 Attention 机制,来选择的捕捉用户兴趣的多样性,但采用 Attention 机制,对于每个目标物品,都需要重新计算用户表示,这在召回阶段是行不通的 (海量),所以 DIN 一般是用于排序。
所以,作者想在召回阶段去建模用户的多兴趣,但以往的方法都不好使,为了解决这个问题,就提出了动态路由的多兴趣网络 MIND。为了推断出用户的多兴趣表示,提出了一个多兴趣提取层,该层使用动态路由机制自动的能将用户的历史行为聚类,然后每个类簇中产生一个表示向量,这个向量能代表用户某种特定的兴趣,而多个类簇的多个向量合起来,就能表示用户广泛的兴趣了。
这就是 MIND 的提出动机以及初步思路了,这里面的核心是 Multi-interest extractor layer, 而这里面重点是动态路由与胶囊网络,所以接下来先补充这方面的相关知识。
胶囊网络与动态路由机制
胶囊网络初识
Hinton 大佬在 2011 年的时候,就首次提出了 "胶囊" 的概念, "胶囊" 可以看成是一组聚合起来输出整个向量的小神经元组合,这个向量的每个维度 (每个小神经元),代表着某个实体的某个特征。
胶囊网络其实可以和神经网络对比着看可能更好理解,我们知道神经网络的每一层的神经元输出的是单个的标量值,接收的输入,也是多个标量值,所以这是一种 value to value 的形式,而胶囊网络每一层的胶囊输出的是一个向量值,接收的输入也是多个向量,所以它是 vector to vector 形式的。来个图对比下就清楚了:
左边的图是普通神经元的计算示意,而右边是一个胶囊内部的计算示意图。 神经元这里不过多解释,这里主要是剖析右边的这个胶囊计算原理。从上图可以看出, 输入是两个向量
这里的 Squash 操作可以简单看下,主要包括两部分,右边的那部分其实就是向量归一化操作,把 norm 弄成 1,而左边那部分算是一个非线性操作,如果
这样就完成了一个胶囊的计算,但有两点需要注意:
- 这里的
参数是可学习的,和神经网络一样, 通过 BP 算法更新 - 这里的
参数不是 BP 算法学习出来的,而是采用动态路由机制现场算出来的,这个非常类似于 pooling 层,我们知道 pooling 层的参数也不是学习的,而是根据前面的输入现场取最大或者平均计算得到的。
所以这里的问题,就是怎么通过动态路由机制得到
动态路由机制原理
我们先来一个胶囊结构:
这个
首先我们先初始化
在每一次迭代中,首先把分数转成权重,然后加权求和得到
如果当前的
与某一个输入胶囊 非常相关,即内积结果很大的话,那么相应的下一轮的该输入胶囊对应的 就会变大, 那么, 在计算下一轮的 的时候,与上一轮 相关的 就会占主导,相当于下一轮的 与上一轮中和他相关的那些 之间的路径权重会大一些,这样从空间点的角度观察,就相当于 点朝与它相关的那些 点更近了一点。
通过若干次迭代之后,得到最后的输出胶囊向量
而从另一个角度来考虑,这个过程其实像是聚类的过程,因为胶囊的输出向量
所以,这个动态路由机制的计算设计的还是比较巧妙的, 下面是上述过程的展开计算过程, 这个和 RNN 的计算有点类似: 这样就完成了一个胶囊内部的计算过程了。
Ok, 有了上面的这些铺垫,再来看 MIND 就会比较简单了。下面正式对 MIND 模型的网络架构剖析。
MIND 模型的网络结构与细节剖析
网络整体结构
MIND 网络的架构如下: 初步先分析这个网络结构的运作: 首先接收的输入有三类特征,用户 base 属性,历史行为属性以及商品的属性,用户的历史行为序列属性过了一个多兴趣提取层得到了多个兴趣胶囊,接下来和用户 base 属性拼接过 DNN,得到了交互之后的用户兴趣。然后在训练阶段,用户兴趣和当前商品向量过一个 label-aware attention,然后求 softmax 损失。 在服务阶段,得到用户的向量之后,就可以直接进行近邻检索,找候选商品了。 这就是宏观过程,但是,多兴趣提取层以及这个 label-aware attention 是在做什么事情呢? 如果单独看这个图,感觉得到多个兴趣胶囊之后,直接把这些兴趣胶囊以及用户的 base 属性拼接过全连接,那最终不就成了一个用户向量,此时 label-aware attention 的意义不就没了? 所以这个图初步感觉画的有问题,和论文里面描述的不符。所以下面先以论文为主,正式开始描述具体细节。
任务目标
召回任务的目标是对于每一个用户
模型的输入
对于模型,每个样本的输入可以表示为一个三元组:
任务描述
MIND 的核心任务是学习一个从原生特征映射到用户表示的函数,用户表示定义为:
其中,
目标物品
其中,
最终结果
根据评分函数检索(根据目标物品与用户表示向量的内积的最大值作为相似度依据,DIN 的 Attention 部分也是以这种方式来衡量两者的相似度),得到 top N 个候选项:
Embedding & Pooling 层
Embedding 层的输入由三部分组成,用户属性
- 对于
的 id 特征(年龄、性别等)是将其 Embedding 的向量进行 Concat,组成用户属性 Embedding ; - 目标物品
通常包含其他分类特征 id(品牌 id、店铺 id 等) ,这些特征有利于物品的冷启动问题,需要将所有的分类特征的 Embedding 向量进行平均池化,得到一个目标物品向量 ; - 对于用户行为
,由物品的 Embedding 向量组成用户行为 Embedding 列表 , 当然这里不仅只有物品 embedding 哈,也可能有类别,品牌等其他的 embedding 信息。
Multi-Interest Extractor Layer (核心)
作者认为,单一的向量不足以表达用户的多兴趣。所以作者采用多个表示向量来分别表示用户不同的兴趣。通过这个方式,在召回阶段,用户的多兴趣可以分别考虑,对于兴趣的每一个方面,能够更精确的进行物品检索。
为了学习多兴趣表示,作者利用胶囊网络表示学习的动态路由将用户的历史行为分组到多个簇中。来自一个簇的物品应该密切相关,并共同代表用户兴趣的一个特定方面。
由于多兴趣提取器层的设计灵感来自于胶囊网络表示学习的动态路由,所以这里作者回顾了动态路由机制。当然,如果之前对胶囊网络或动态路由不了解,这里读起来就会有点艰难,但由于我上面进行了铺垫,这里就直接拿过原文并解释即可。
动态路由
动态路由是胶囊网络中的迭代学习算法,用于学习低水平胶囊和高水平胶囊之间的路由对数(logit)
我们假设胶囊网络有两层,即低水平胶囊
其中
通过计算路由对数,将高阶胶囊
其中
最后,应用一个非线性的 “压缩” 函数来获得一个高阶胶囊的向量【胶囊网络向量的模表示由胶囊所代表的实体存在的概率】
路由过程重复进行 3 次达到收敛。当路由结束,高阶胶囊值
Ok,下面我们开始解释,其实上面说的这些就是胶囊网络的计算过程,只不过和之前所用的符号不一样了。这里拿个图: 首先,论文里面也是个两层的胶囊网络,低水平层 -> 高水平层。 低水平层有
单独拿出每个
只不过这里的符合和上图中的不太一样,这里的
所以,这样解释完之后就会发现,其实上面的一顿操作就是说的传统的动态路由机制。
B2I 动态路由
作者设计的多兴趣提取层就是就是受到了上述胶囊网络的启发。
如果把用户的行为序列看成是行为胶囊, 把用户的多兴趣看成兴趣胶囊,那么多兴趣提取层就是利用动态路由机制学习行为胶囊 ->
兴趣胶囊的映射关系。但是原始路由算法无法直接应用于处理用户行为数据。因此,提出了行为 (Behavior) 到兴趣 (Interest)(B2I)动态路由来自适应地将用户的行为聚合到兴趣表示向量中,它与原始路由算法有三个不同之处:
- 共享双向映射矩阵。在初始动态路由中,使用固定的或者说共享的双线性映射矩阵
而不是单独的双线性映射矩阵, 在原始的动态路由中,对于每个输出胶囊 ,都会有对应的 ,而这里是每个输出胶囊,都共用一个 矩阵。 原因有两个:- 一方面,用户行为是可变长度的,从几十个到几百个不等,因此使用共享的双线性映射矩阵是有利于泛化。
- 另一方面,希望兴趣胶囊在同一个向量空间中,但不同的双线性映射矩阵将兴趣胶囊映射到不同的向量空间中。因为映射矩阵的作用就是对用户的行为胶囊进行线性映射嘛, 由于用户的行为序列都是商品,所以希望经过映射之后,到统一的商品向量空间中去。路由对数计算如下:
其中,
- 随机初始化路由对数。由于利用共享双向映射矩阵
,如果再初始化路由对数为 0 将导致相同的初始的兴趣胶囊。随后的迭代将陷入到一个不同兴趣胶囊在所有的时间保持相同的情景。因为每个输出胶囊的运算都一样了嘛 (除非迭代的次数不同,但这样也会导致兴趣胶囊都很类似),为了减轻这种现象,作者通过高斯分布进行随机采样来初始化路由对数 ,让初始兴趣胶囊与其他每一个不同,其实就是希望在计算每个输出胶囊的时候,通过随机化的方式,希望这几个聚类中心离得远一点,这样才能表示出广泛的用户兴趣 (我们已经了解这个机制就仿佛是聚类,而计算过程就是寻找聚类中心)。 - 动态的兴趣数量,兴趣数量就是聚类中心的个数,由于不同用户的历史行为序列不同,那么相应的,其兴趣胶囊有可能也不一样多,所以这里使用了一种启发式方式自适应调整聚类中心的数量,即
值。
这种调整兴趣胶囊数量的策略可以为兴趣较小的用户节省一些资源,包括计算和内存资源。这个公式不用多解释,与行为序列长度成正比。
最终的 B2I 动态路由算法如下: 应该很好理解了吧。
Label-aware Attention Layer
通过多兴趣提取器层,从用户的行为 embedding 中生成多个兴趣胶囊。不同的兴趣胶囊代表用户兴趣的不同方面,相应的兴趣胶囊用于评估用户对特定类别的偏好。所以,在训练的期间,最后需要设置一个 Label-aware 的注意力层,对于当前的商品,根据相关性选择最相关的兴趣胶囊。这里其实就是一个普通的注意力机制,和 DIN 里面的那个注意力层基本上是一模一样,计算公式如下:
首先这里的
理解:
小意味着所有的相似程度都缩小了, 使得之间的差距会变小,所以相当于每个胶囊都会受到关注,而越大的话,使得各个相似性差距拉大,相似程度越大的会更大,就类似于贫富差距, 最终使得只关注于比较大的胶囊。
训练与服务
得到用户向量
目标函数是:
其中
训练结束后,抛开 label-aware 注意力层,MIND 网络得到一个用户表示映射函数
这就是整个 MIND 模型的细节了。
MIND 模型之简易代码复现
下面参考 Deepctr,用简易的代码实现下 MIND,并在新闻推荐的数据集上进行召回任务。
整个代码架构
整个 MIND 模型算是参考 deepmatch 修改的一个简易版本:
def MIND(user_feature_columns, item_feature_columns, num_sampled=5, k_max=2, p=1.0, dynamic_k=False, user_dnn_hidden_units=(64, 32),
dnn_activation='relu', dnn_use_bn=False, l2_reg_dnn=0, l2_reg_embedding=1e-6, dnn_dropout=0, output_activation='linear', seed=1024):
"""
:param k_max: 用户兴趣胶囊的最大个数
"""
# 目前这里只支持item_feature_columns为1的情况,即只能转入item_id
if len(item_feature_columns) > 1:
raise ValueError("Now MIND only support 1 item feature like item_id")
# 获取item相关的配置参数
item_feature_column = item_feature_columns[0]
item_feature_name = item_feature_column.name
item_vocabulary_size = item_feature_column.vocabulary_size
item_embedding_dim = item_feature_column.embedding_dim
behavior_feature_list = [item_feature_name]
# 为用户特征创建Input层
user_input_layer_dict = build_input_layers(user_feature_columns)
item_input_layer_dict = build_input_layers(item_feature_columns)
# 将Input层转化成列表的形式作为model的输入
user_input_layers = list(user_input_layer_dict.values())
item_input_layers = list(item_input_layer_dict.values())
# 筛选出特征中的sparse特征和dense特征,方便单独处理
sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), user_feature_columns)) if user_feature_columns else []
dense_feature_columns = list(filter(lambda x: isinstance(x, DenseFeat), user_feature_columns)) if user_feature_columns else []
varlen_feature_columns = list(filter(lambda x: isinstance(x, VarLenSparseFeat), user_feature_columns)) if user_feature_columns else []
# 由于这个变长序列里面只有历史点击文章,没有类别啥的,所以这里直接可以用varlen_feature_columns
# deepctr这里单独把点击文章这个放到了history_feature_columns
seq_max_len = varlen_feature_columns[0].maxlen
# 构建embedding字典
embedding_layer_dict = build_embedding_layers(user_feature_columns+item_feature_columns)
# 获取当前的行为特征(doc)的embedding,这里面可能又多个类别特征,所以需要pooling下
query_embed_list = embedding_lookup(behavior_feature_list, item_input_layer_dict, embedding_layer_dict) # 长度为1
# 获取行为序列(doc_id序列, hist_doc_id) 对应的embedding,这里有可能有多个行为产生了行为序列,所以需要使用列表将其放在一起
keys_embed_list = embedding_lookup([varlen_feature_columns[0].name], user_input_layer_dict, embedding_layer_dict) # 长度为1
# 用户离散特征的输入层与embedding层拼接
dnn_input_emb_list = embedding_lookup([col.name for col in sparse_feature_columns], user_input_layer_dict, embedding_layer_dict)
# 获取dense
dnn_dense_input = []
for fc in dense_feature_columns:
if fc.name != 'hist_len': # 连续特征不要这个
dnn_dense_input.append(user_input_layer_dict[fc.name])
# 把keys_emb_list和query_emb_listpooling操作, 这是因为可能每个商品不仅有id,还可能用类别,品牌等多个embedding向量,这种需要pooling成一个
history_emb = PoolingLayer()(NoMask()(keys_embed_list)) # (None, 50, 8)
target_emb = PoolingLayer()(NoMask()(query_embed_list)) # (None, 1, 8)
hist_len = user_input_layer_dict['hist_len']
# 胶囊网络
# (None, 2, 8) 得到了两个兴趣胶囊
high_capsule = CapsuleLayer(input_units=item_embedding_dim, out_units=item_embedding_dim,
max_len=seq_max_len, k_max=k_max)((history_emb, hist_len))
# 把用户的其他特征拼接到胶囊网络上来
if len(dnn_input_emb_list) > 0 or len(dnn_dense_input) > 0:
user_other_feature = combined_dnn_input(dnn_input_emb_list, dnn_dense_input)
# (None, 2, 32) 这里会发现其他的用户特征是每个胶囊复制了一份,然后拼接起来
other_feature_tile = tf.keras.layers.Lambda(tile_user_otherfeat, arguments={'k_max': k_max})(user_other_feature)
user_deep_input = Concatenate()([NoMask()(other_feature_tile), high_capsule]) # (None, 2, 40)
else:
user_deep_input = high_capsule
# 接下来过一个DNN层,获取最终的用户表示向量 如果是三维输入, 那么最后一个维度与w相乘,所以这里如果不自己写,可以用Dense层的列表也可以
user_embeddings = DNN(user_dnn_hidden_units, dnn_activation, l2_reg_dnn,
dnn_dropout, dnn_use_bn, output_activation=output_activation, seed=seed,
name="user_embedding")(user_deep_input) # (None, 2, 8)
# 接下来,过Label-aware layer
if dynamic_k:
user_embedding_final = LabelAwareAttention(k_max=k_max, pow_p=p,)((user_embeddings, target_emb, hist_len))
else:
user_embedding_final = LabelAwareAttention(k_max=k_max, pow_p=p,)((user_embeddings, target_emb))
# 接下来
item_embedding_matrix = embedding_layer_dict[item_feature_name] # 获取doc_id的embedding层
item_index = EmbeddingIndex(list(range(item_vocabulary_size)))(item_input_layer_dict[item_feature_name]) # 所有doc_id的索引
item_embedding_weight = NoMask()(item_embedding_matrix(item_index)) # 拿到所有item的embedding
pooling_item_embedding_weight = PoolingLayer()([item_embedding_weight]) # 这里依然是当可能不止item_id,或许还有brand_id, cat_id等,需要池化
# 这里传入的是整个doc_id的embedding, user_embedding, 以及用户点击的doc_id,然后去进行负采样计算损失操作
output = SampledSoftmaxLayer(num_sampled)([pooling_item_embedding_weight, user_embedding_final, item_input_layer_dict[item_feature_name]])
model = Model(inputs=user_input_layers+item_input_layers, outputs=output)
# 下面是等模型训练完了之后,获取用户和item的embedding
model.__setattr__("user_input", user_input_layers)
model.__setattr__("user_embedding", user_embeddings)
model.__setattr__("item_input", item_input_layers)
model.__setattr__("item_embedding", get_item_embedding(pooling_item_embedding_weight, item_input_layer_dict[item_feature_name]))
return model
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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
简单说下流程, 函数式 API 搭建模型的方式,首先我们需要传入封装好的用户特征描述以及 item 特征描述,比如:
# 建立模型
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'),
DenseFeat('hist_len', 1),
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),
]
doc_feature_columns = [
SparseFeat('click_doc_id', feature_max_idx['article_id'], embedding_dim)
# 这里后面也可以把文章的类别画像特征加入
]
2
3
4
5
6
7
8
9
10
11
12
13
14
首先, 函数会对传入的这种特征建立模型的 Input 层,主要是 build_input_layers
函数。建立完了之后,获取到 Input 层列表,这个是为了最终定义模型用的,keras 要求定义模型的时候是列表的形式。
接下来是选出 sparse 特征和 Dense 特征来,这个也是常规操作了,因为不同的特征后面处理方式不一样,对于 sparse 特征,后面要接 embedding 层,Dense 特征的话,直接可以拼接起来。这就是筛选特征的 3 行代码。
接下来,是为所有的离散特征建立 embedding 层,通过函数 build_embedding_layers
。建立完了之后,把 item 相关的 embedding 层与对应的 Input 层接起来,作为 query_embed_list, 而用户历史行为序列的 embedding 层与 Input 层接起来作为 keys_embed_list,这两个有单独的用户。而 Input 层与 embedding 层拼接是通过 embedding_lookup
函数完成的。 这样完成了之后,就能通过 Input 层 - embedding 层拿到 item 的系列 embedding,以及历史序列里面 item 系列 embedding,之所以这里是系列 embedding,是有可能不止 item_id 这一个特征,还可能有品牌 id, 类别 id 等好几个,所以接下来把系列 embedding 通过 pooling 操作,得到最终表示 item 的向量。 就是这两行代码:
# 把keys_emb_list和query_emb_listpooling操作, 这是因为可能每个商品不仅有id,还可能用类别,品牌等多个embedding向量,这种需要pooling成一个
history_emb = PoolingLayer()(NoMask()(keys_embed_list)) # (None, 50, 8)
target_emb = PoolingLayer()(NoMask()(query_embed_list)) # (None, 1, 8)
2
3
而像其他的输入类别特征, 依然是 Input 层与 embedding 层拼起来,留着后面用,这个存到了 dnn_input_emb_list 中。 而 dense 特征, 不需要 embedding 层,直接通过 Input 层获取到,然后存到列表里面,留着后面用。
上面得到的 history_emb,就是用户的历史行为序列,这个东西接下来要过兴趣提取层,去学习用户的多兴趣,当然这里还需要传入行为序列的真实长度。因为每个用户行为序列不一样长,通过 mask 让其等长了,但是真实在胶囊网络计算的时候,这些填充的序列是要被 mask 掉的。所以必须要知道真实长度。
# 胶囊网络
# (None, 2, 8) 得到了两个兴趣胶囊
high_capsule = CapsuleLayer(input_units=item_embedding_dim, out_units=item_embedding_dim,max_len=seq_max_len, k_max=k_max)((history_emb, hist_len))
2
3
通过这步操作,就得到了两个兴趣胶囊。 至于具体细节,下一节看。 然后把用户的其他特征拼接上来,这里有必要看下代码究竟是怎么拼接的:
# 把用户的其他特征拼接到胶囊网络上来
if len(dnn_input_emb_list) > 0 or len(dnn_dense_input) > 0:
user_other_feature = combined_dnn_input(dnn_input_emb_list, dnn_dense_input)
# (None, 2, 32) 这里会发现其他的用户特征是每个胶囊复制了一份,然后拼接起来
other_feature_tile = tf.keras.layers.Lambda(tile_user_otherfeat, arguments={'k_max': k_max})(user_other_feature)
user_deep_input = Concatenate()([NoMask()(other_feature_tile), high_capsule]) # (None, 2, 40)
else:
user_deep_input = high_capsule
2
3
4
5
6
7
8
这里会发现,使用了一个 Lambda 层,这个东西的作用呢,其实是将用户的其他特征在胶囊个数的维度上复制了一份,再拼接,这就相当于在每个胶囊的后面都拼接上了用户的基础特征。这样得到的维度就成了 (None, 2, 40),2 是胶囊个数, 40 是兴趣胶囊的维度 + 其他基础特征维度总和。这样拼完了之后,接下来过全连接层
# 接下来过一个DNN层,获取最终的用户表示向量 如果是三维输入, 那么最后一个维度与w相乘,所以这里如果不自己写,可以用Dense层的列表也可以
user_embeddings = DNN(user_dnn_hidden_units, dnn_activation, l2_reg_dnn,
dnn_dropout, dnn_use_bn, output_activation=output_activation, seed=seed,
name="user_embedding")(user_deep_input) # (None, 2, 8)
2
3
4
最终得到的是 (None, 2, 8) 的向量,这样就解决了之前的那个疑问, 最终得到的兴趣向量个数并不是 1 个,而是多个兴趣向量了,因为上面用户特征拼接,是每个胶囊后面都拼接一份同样的特征。另外,就是原来 DNN 这里的输入还可以是 3 维的,这样进行运算的话,是最后一个维度与 W 进行运算,相当于只在第 3 个维度上进行了降维操作后者非线性操作,这样得到的兴趣个数是不变的。
这样,有了两个兴趣的输出之后,接下来,就是过 LabelAwareAttention 层了,对这两个兴趣向量与当前 item 的相关性加注意力权重,最后变成 1 个用户的最终向量。
user_embedding_final = LabelAwareAttention(k_max=k_max, pow_p=p,)((user_embeddings, target_emb))
这样,就得到了用户的最终表示向量,当然这个操作仅是训练的时候,服务的时候是拿的上面 DNN 的输出,即多个兴趣,这里注意一下。
拿到了最终的用户向量,如何计算损失呢? 这里用了负采样层进行操作。关于这个层具体的原理,后面我们可能会出一篇文章总结。
接下来有几行代码也需要注意:
# 下面是等模型训练完了之后,获取用户和item的embedding
model.__setattr__("user_input", user_input_layers)
model.__setattr__("user_embedding", user_embeddings)
model.__setattr__("item_input", item_input_layers)
model.__setattr__("item_embedding", get_item_embedding(pooling_item_embedding_weight, item_input_layer_dict[item_feature_name]))
2
3
4
5
这几行代码是为了模型训练完,我们给定输入之后,拿 embedding 用的,设置好了之后,通过:
user_embedding_model = Model(inputs=model.user_input, outputs=model.user_embedding)
item_embedding_model = Model(inputs=model.item_input, outputs=model.item_embedding)
user_embs = user_embedding_model.predict(test_user_model_input, batch_size=2 ** 12)
# user_embs = user_embs[:, i, :] # i in [0,k_max) if MIND
item_embs = item_embedding_model.predict(all_item_model_input, batch_size=2 ** 12)
2
3
4
5
6
这样就能拿到用户和 item 的 embedding, 接下来近邻检索完成召回过程。 注意,MIND 的话,这里是拿到的多个兴趣向量的。
总结
今天这篇文章整理的 MIND,这是一个多兴趣的召回模型,核心是兴趣提取层,该层通过动态路由机制能够自动的对用户的历史行为序列进行聚类,得到多个兴趣向量,这样能在召回阶段捕获到用户的广泛兴趣,从而召回更好的候选商品。
参考:
- Multi-Interest Network with Dynamic Routing for Recommendation at Tmall
- AI 上推荐 之 MIND (动态路由与胶囊网络的奇光异彩)
- Dynamic Routing Between Capsule
- CIKM2019|MIND--- 召回阶段的多兴趣模型
- B 站胶囊网络课程
- 胶囊网络识别交通标志