浅层模型的FM, FFM, HOFM, FwFM到深层模型的DNN, PNN, NFM, AFM, DeepFM, DCN, xDeepFM, FiBiNet等,
关于特征交叉方向的模型paper, FiBinet模型通过SENET动态选择有用的交互特征, 然后再用一个双线性交互进行增强提高。在模型上并没有引入很复杂的结构,却能起到比较不错的效果。
本文中的AutoInt(Automatic Feature Interaction)模型是2019年发表在CIKM上的文章,重点也是在特征交互上,用到transformer结构,通过多头的自注意力机制显示地构造高阶特征,有效的提升了模型的效果。
和xdeepFM类似,针对目前很多浅层模型无法学习高阶的交互, 而DNN模型能学习高阶交互,但确是隐性学习,缺乏可解释性。而transformer把离散特征通过多个这样的交叉块堆积,学习到任意高阶的交互。
在Fibinet中整理到的在模型的特征重要性选择和特征交互时的两大思路:
- 特征重要性选择 — 想到SENet和Attention机制
- 特征交互 — 想到內积,哈达玛和双线性
- 重要的一些网络模块:
- DNN – 高阶隐性交互
- FM – 低阶显性交互
- CrossNet, CIN,transformer — 高阶显性交互
AutoInt把上面两个东西融合到了一起,在多头自注意力机制选择的时候, 自动完成了特征交互。并且还能学习到任意高阶交互。本文结构如下:
- AutoInt模型的理论及论文细节
- AutoInt模型的简单复现及结构解释
- 总结
AutoInt模型的理论及论文细节
动机
- 浅层的模型会受到交叉阶数的限制,没法完成高阶交叉
- 深层模型的DNN在学习高阶隐性交叉的效果并不是很好, 且不具有可解释性
AutoInt模型的结构
Input Layer
输入层用到的特征主要是离散型特征和连续性特征,经过embedding层转成低维稠密的向量,连续性特征,这里并没有经过分桶离散化,而是直接走embedding。类似于预训练时候的思路,先通过item_id把连续型特征与类别特征关联起来,最简单的做法,就是把item_id拿过来,经过embedding层取出对应的embedding之后,再乘上连续值即可, 所以这个连续值需要预处理归一化的。
所以模型整体的输入如下:
x
=
[
x
1
;
x
2
;
…
;
x
M
]
\mathbf{x}=\left[\mathbf{x}_{1} ; \mathbf{x}_{2} ; \ldots ; \mathbf{x}_{\mathbf{M}}\right]
x=[x1;x2;…;xM]
这里的
M
M
M 表示特征的个数,
X
1
,
X
2
X_{1}, X_{2}
X1,X2 这是离散型特征, one-hot的形式,而
X
M
X_{M}
XM 在这里是连续性特征。经过embedding层就是上面的做法。
Embedding Layer
embedding层的作用是把高维稀疏的特征转成低维稠密, 离散型的特征一般是取出对应的embedding向量即可,具体计算如下:
e
i
=
V
i
x
i
\mathbf{e}_{\mathbf{i}}=\mathbf{V}_{\mathrm{i}} \mathbf{x}_{\mathbf{i}}
ei=Vixi
对于第
i
i
i 个离散特征,直接第
i
i
i 个嵌入矩阵
V
i
V_{i}
Vi 乘以one-hot向量,取出对应位置的embedding。
当然,如果输入的时候不是个one-hot,而 是个multi-hot的形式,那么对应的embedding输出是各个embedding求平均得到的。
e
i
=
1
q
V
i
x
i
\mathbf{e}_{\mathbf{i}}=\frac{1}{q} \mathbf{V}_{\mathbf{i}} \mathbf{x}_{\mathbf{i}}
ei=q1Vixi
而对于连续特征,也是过一个embedding矩阵取相应的embedding,不过,最后要乘一个连续值
e
m
=
v
m
x
m
\mathbf{e}_{\mathbf{m}}=\mathbf{v}_{\mathbf{m}} x_{m}
em=vmxm
这样,不管是连续特征,离散特征还是变长的离散特征,经过embedding之后,都能得到等长的embedding向量。把这个向量拼接到一块,就得到了交互层的输入。
Interacting Layer
这个是本篇论文的核心,描述了transformer块的前向传播过程。
通过embedding层,得到
M
\mathrm{M}
M 个向量
e
1
,
…
e
M
e_{1}, \ldots e_{M}
e1,…eM ,假设向量的维度是
d
d
d 维,那么这个就是一个
d
×
M
d \times M
d×M 的矩阵,我们定一个符号
X
X
X 。 接下来我们基于这个矩阵
X
X
X ,做三次变换,也就是分别乘以三个矩阵
W
k
(
h
)
,
W
q
(
h
)
,
W
v
(
h
)
W_{k}^{(h)}, W_{q}^{(h)}, W_{v}^{(h)}
Wk(h),Wq(h),Wv(h) ,这三个矩阵的维度是
d
′
×
d
d^{\prime} \times d
d′×d 的话,那么我们 就会得到三个结果:
Q
(
h
)
=
W
q
(
h
)
×
X
K
(
h
)
=
W
k
(
h
)
×
X
V
(
h
)
=
W
v
(
h
)
×
X
\begin{aligned} &Q^{(h)}=W_{q}^{(h)} \times X \\ &K^{(h)}=W_{k}^{(h)} \times X \\ &V^{(h)}=W_{v}^{(h)} \times X \end{aligned}
Q(h)=Wq(h)×XK(h)=Wk(h)×XV(h)=Wv(h)×X
这三个矩阵都是
d
′
×
M
d^{\prime} \times M
d′×M 的。这其实就完成了一个Head的操作。所谓的自注意力,就是
X
X
X 通过三次变换得到的结果之间,通过交互得到相关性,并通过相关性进行加权汇总,全是
X
X
X 自发的。那么是怎么做到的呢? 首先,先进行这样的操作:
Score
(
Q
h
,
K
h
)
=
Q
h
×
K
h
T
\operatorname{Score}\left(Q^{h}, K^{h}\right)=Q^{h} \times K^{h^{T}}
Score(Qh,Kh)=Qh×KhT
这个结果得到的是一个
d
′
×
d
′
d^{\prime} \times d^{\prime}
d′×d′ 的矩阵,那么这个操作到底是做了一个什么事情呢?
假设这里的
c
1
.
.
c
6
c_{1} . . c_{6}
c1..c6 是我们的 6 个特征,而每一行代表每个特征的embedding向量,这样两个矩阵相乘,相当于得到了当前特征与其它特征两两之间的内积值,而内积可以表示两个向量之间的相似程度。所以得到的结果每一行,就代表当前这个特征与其它特征的相似性程度。
对
Score
(
Q
h
,
K
h
)
\operatorname{Score}\left(Q^{h}, K^{h}\right)
Score(Qh,Kh) , 在最后一个维度上进行softmax,根据相似性得到了权重信息,这其实就是把相似性分数归一化到了0-1之间
Attention
(
Q
h
,
K
h
)
=
Softmax
(
Score
(
Q
h
,
K
h
)
)
\operatorname{Attention}\left(Q^{h}, K^{h}\right)=\operatorname{Softmax}\left(\operatorname{Score}\left(Q^{h}, K^{h}\right)\right)
Attention(Qh,Kh)=Softmax(Score(Qh,Kh))
接下来,我们再进行这样的一步操作
E
(
h
)
=
Attention
(
Q
h
,
K
h
)
×
V
E^{(h)}=\operatorname{Attention}\left(Q^{h}, K^{h}\right) \times V
E(h)=Attention(Qh,Kh)×V
这样就得到了
d
′
×
M
d^{\prime} \times M
d′×M 的矩阵
E
E
E ,这步操作,其实就是一个加权汇总的过程,对于每个特征,先求与其它特征的相似度,然后得到一个权重,再回乘到各自的特征向量再求和。只不过这里的特征是经过了一次线性变化的过程,降维到了
d
′
d^{\prime}
d′ 。
上面是我从矩阵的角度又过了一遍,这个是直接针对所有的特征向量一部到位。
论文里面的从单个特征的角度去描述的,只说了一个矩 阵向量过多头注意力的操作。
α
m
,
k
(
h
)
=
exp
(
ψ
(
h
)
(
e
m
,
e
k
)
)
∑
l
=
1
M
exp
(
ψ
(
h
)
(
e
m
,
e
1
)
)
ψ
(
h
)
(
e
m
,
e
k
)
=
⟨
W
Query
(
h
)
e
m
,
W
Key
(
h
)
e
k
⟩
e
~
m
(
h
)
=
∑
k
=
1
M
α
m
,
k
(
h
)
(
W
Value
(
h
)
e
k
)
\begin{gathered} \alpha_{\mathbf{m}, \mathbf{k}}^{(\mathbf{h})}=\frac{\exp \left(\psi^{(h)}\left(\mathbf{e}_{\mathbf{m}}, \mathbf{e}_{\mathbf{k}}\right)\right)}{\sum_{l=1}^{M} \exp \left(\psi^{(h)}\left(\mathbf{e}_{\mathbf{m}}, \mathbf{e}_{1}\right)\right)} \\ \psi^{(h)}\left(\mathbf{e}_{\mathbf{m}}, \mathbf{e}_{\mathbf{k}}\right)=\left\langle\mathbf{W}_{\text {Query }}^{(\mathbf{h})} \mathbf{e}_{\mathbf{m}}, \mathbf{W}_{\text {Key }}^{(\mathbf{h})} \mathbf{e}_{\mathbf{k}}\right\rangle \\ \tilde{\mathbf{e}}_{\mathbf{m}}^{(\mathbf{h})}=\sum_{k=1}^{M} \alpha_{\mathbf{m}, \mathbf{k}}^{(\mathbf{h})}\left(\mathbf{W}_{\text {Value }}^{(\mathbf{h})} \mathbf{e}_{\mathbf{k}}\right) \end{gathered}
αm,k(h)=∑l=1Mexp(ψ(h)(em,e1))exp(ψ(h)(em,ek))ψ(h)(em,ek)=⟨WQuery (h)em,WKey (h)ek⟩e~m(h)=k=1∑Mαm,k(h)(WValue (h)ek)
这里会更好懂一些,就是相当于上面矩阵的每一行操作拆开了。
首先,整个拼接起来的embedding矩阵还是过三个参数矩阵得到 Q , K , V Q, K, V Q,K,V ,然后是每一行单独操作的方式,对于某个特征向量 e k e_{k} ek ,与其它的特征两两内积得到权重,然后在 s o f t m a x t s o f t m a x_{t} softmaxt ,回乘到对应向量, 然后进行求和就得到了融合其它特征信息的新向量。具体过程如图:
上面的过程是用了一个头,类似于从一个角度去看特征之间的相关关系,用论文里面的话讲,这是从一个子空间去看,如果想从多个角度看,可以用多个头,即换不同的矩阵
W
q
,
W
k
,
W
v
W_{q}, W_{k}, W_{v}
Wq,Wk,Wv 得到不同的
Q
,
K
,
V
Q, K, V
Q,K,V 然后得到不同的
e
m
e_{m}
em ,每个
e
m
e_{m}
em 是
d
′
×
1
d^{\prime} \times 1
d′×1 的。
然后,多个头的结果concat起来
e
~
m
=
e
~
m
(
1
)
⊕
e
~
m
(
2
)
⊕
⋯
⊕
e
~
m
(
H
)
\tilde{\mathbf{e}}_{\mathrm{m}}=\tilde{\mathbf{e}}_{\mathrm{m}}^{(1)} \oplus \tilde{\mathbf{e}}_{\mathrm{m}}^{(2)} \oplus \cdots \oplus \tilde{\mathbf{e}}_{\mathbf{m}}^{(\mathbf{H})}
e~m=e~m(1)⊕e~m(2)⊕⋯⊕e~m(H)
接下来,过一个残差网络层,这是为了保留原始的特征信息
e
m
Res
=
ReL
U
(
e
~
m
+
W
Res
e
m
)
\mathbf{e}_{\mathbf{m}}^{\operatorname{Res}}=\operatorname{ReL} U\left(\tilde{\mathbf{e}}_{\mathbf{m}}+\mathbf{W}_{\text {Res }} \mathbf{e}_{\mathbf{m}}\right)
emRes=ReLU(e~m+WRes em)
这里的
e
m
e_{m}
em 是
d
×
1
d \times 1
d×1 的向量,
W
R
e
s
W_{R e s}
WRes 是
d
′
H
×
d
d^{\prime} H \times d
d′H×d 的矩阵,最后得到的
e
m
R
e
s
e_{m}^{R e s}
emRes 是
d
′
H
×
1
d^{\prime} H \times 1
d′H×1 的向量,这是其中的一个特征,如果是
M
M
M 个特征堆叠的话,最終就是
d
′
H
M
×
1
d^{\prime} H M \times 1
d′HM×1 的矩阵,这个就是Interacting Layer的结果输出。
Output Layer
输出层就加一层全连接映射出输出值即可:
y
^
=
σ
(
w
T
(
e
1
R
e
s
⊕
e
2
R
e
s
⊕
⋯
⊕
e
M
Res
)
+
b
)
\hat{y}=\sigma\left(\mathbf{w}^{\mathrm{T}}\left(\mathbf{e}_{1}^{\mathbf{R e s}} \oplus \mathbf{e}_{2}^{\mathbf{R e s}} \oplus \cdots \oplus \mathbf{e}_{\mathbf{M}}^{\text {Res }}\right)+b\right)
y^=σ(wT(e1Res⊕e2Res⊕⋯⊕eMRes )+b)
这里的
W
W
W 是
d
′
H
M
×
1
d^{\prime} H M \times 1
d′HM×1 的, 最終得到的是一个概率值了, 接下来交叉熵损失更新模型参数即可。
Autolnt的前向传播过程梳理完毕。
AutoInt的分析
论文里面分析了为啥AutoInt能建模任意的高阶交互以及时间复杂度和空间复杂度的分析。
关于建模任意的高阶交互, 拿一个transformer块看下, 对于一个transformer块, 我们发现特征之间完成了一个2阶的交互过程,得到的输出里面我们还保留着1阶的原始特征。
那么再经过一个transformer块呢? 这里面就会有2阶和1阶的交互了, 也就是会得到3阶的交互信息。而此时的输出,会保留着第一个transformer的输出信息特征。再过一个transformer块的话,就会用4阶的信息交互信息, 其实就相当于, 第n个transformer里面会建模出n + 1阶交互来, 与CrossNet有异曲同工之妙,
无法是中间交互时的方式不一样。 前者是bit-wise级别的交互,而后者是vector-wise的交互。所以, AutoInt是可以建模任意高阶特征的交互的,并且这种交互还是显性。
关于时间复杂度和空间复杂度,空间复杂度是O ( L d d ′ H ) 级别的,看参数量即可, 3个W矩阵, H个head,再假设L个transformer块的话,参数量就达到这了。 时间复杂度的话是O ( M H d ′ ( M + d ) )的,论文说如果d和d’很小的话,其实这个模型不算复杂。
更多细节
实验部分的细节,主要是对于一些超参的实验设置,在实验里面,作者首先指出了logloss下降多少算是有效呢?
- AFM准确的说是二阶显性交互基础上加了交互重要性选择的操作, 这里应该是没有在上面加全连接
- xdeepFM这种CIN网络,在实际场景中非常难部署,不实用
- AutoInt的交互层2-3层差不多, embedding维度16-24
- 在AutoInt上面加2-3层的全连接会有点提升,但是提升效果并不是很大
AutoInt模型在于给了我们一种特征高阶显性交叉与特征选择性的思路,就是transformer在这里起的功效。更多的应该考虑如何用这种思路或者这个交互模块,而不是直接搬模型。
AutoInt模型的简单复现及结构解释
AutoInt模型的核心是Transformer,代码部分主要还是Transformer的实现过程,代码基于Deepctr。
def AutoInt(linear_feature_columns, dnn_feature_columns, att_layer_num=3, att_embedding_size=8, att_head_num=2, att_res=True):
"""
:param att_layer_num: transformer块的数量,一个transformer块里面是自注意力计算 + 残差计算
:param att_embedding_size: 文章里面的d', 自注意力时候的att的维度
:param att_head_num: 头的数量或者自注意力子空间的数量
:param att_res: 是否使用残差网络
"""
# 构建输入层,即所有特征对应的Input()层,这里使用字典的形式返回,方便后续构建模型
dense_input_dict, sparse_input_dict = build_input_layers(linear_feature_columns+dnn_feature_columns)
# 构建模型的输入层,模型的输入层不能是字典的形式,应该将字典的形式转换成列表的形式
# 注意:这里实际的输入预Input层对应,是通过模型输入时候的字典数据的key与对应name的Input层
input_layers = list(dense_input_dict.values()) + list(sparse_input_dict.values())
# 线性部分的计算逻辑 -- linear
linear_logits = get_linear_logits(dense_input_dict, sparse_input_dict, linear_feature_columns)
# 构建维度为k的embedding层,这里使用字典的形式返回,方便后面搭建模型
# 线性层和dnn层统一的embedding层
embedding_layer_dict = build_embedding_layers(linear_feature_columns+dnn_feature_columns, sparse_input_dict, is_linear=False)
# 构造self-att的输入
att_sparse_kd_embed = concat_embedding_list(dnn_feature_columns, sparse_input_dict, embedding_layer_dict, flatten=False)
att_input = Concatenate(axis=1)(att_sparse_kd_embed) # (None, field_num, embed_num)
# 下面的循环,就是transformer的前向传播,多个transformer块的计算逻辑
for _ in range(att_layer_num):
att_input = InteractingLayer(att_embedding_size, att_head_num, att_res)(att_input)
att_output = Flatten()(att_input)
att_logits = Dense(1)(att_output)
# DNN侧的计算逻辑 -- Deep
# 将dnn_feature_columns里面的连续特征筛选出来,并把相应的Input层拼接到一块
dnn_dense_feature_columns = list(filter(lambda x: isinstance(x, DenseFeat), dnn_feature_columns)) if dnn_feature_columns else []
dnn_dense_feature_columns = [fc.name for fc in dnn_dense_feature_columns]
dnn_concat_dense_inputs = Concatenate(axis=1)([dense_input_dict[col] for col in dnn_dense_feature_columns])
# 将dnn_feature_columns里面的离散特征筛选出来,相应的embedding层拼接到一块
dnn_sparse_kd_embed = concat_embedding_list(dnn_feature_columns, sparse_input_dict, embedding_layer_dict, flatten=True)
dnn_concat_sparse_kd_embed = Concatenate(axis=1)(dnn_sparse_kd_embed)
# DNN层的输入和输出
dnn_input = Concatenate(axis=1)([dnn_concat_dense_inputs, dnn_concat_sparse_kd_embed, att_output])
dnn_out = get_dnn_output(dnn_input)
dnn_logits = Dense(1)(dnn_out)
# 三边的结果stack
stack_output = Add()([linear_logits, dnn_logits])
# 输出层
output_layer = Dense(1, activation='sigmoid')(stack_output)
model = Model(input_layers, output_layer)
return model
大部分都是之前见过的模块,唯一改变的地方,就是加了一个Interacting Layer, 这个是一个transformer块,在这里面实现特征交互。而这个的结果输出,最终和DNN的输出结合到一起了。 而这个层,主要就是一个transformer块的前向传播过程。
这应该算是最简单的一个版本了:
class InteractingLayer(Layer):
"""A layer user in AutoInt that model the correction between different feature fields by multi-head self-att mechanism
input: 3维张量, (none, field_num, embedding_size)
output: 3维张量, (none, field_num, att_embedding_size * head_num)
"""
def __init__(self, att_embedding_size=8, head_num=2, use_res=True, seed=2021):
super(InteractingLayer, self).__init__()
self.att_embedding_size = att_embedding_size
self.head_num = head_num
self.use_res = use_res
self.seed = seed
def build(self, input_shape):
embedding_size = int(input_shape[-1])
# 定义三个矩阵Wq, Wk, Wv
self.W_query = self.add_weight(name="query", shape=[embedding_size, self.att_embedding_size * self.head_num],
dtype=tf.float32, initializer=tf.keras.initializers.TruncatedNormal(seed=self.seed))
self.W_key = self.add_weight(name="key", shape=[embedding_size, self.att_embedding_size * self.head_num],
dtype=tf.float32, initializer=tf.keras.initializers.TruncatedNormal(seed=self.seed+1))
self.W_value = self.add_weight(name="value", shape=[embedding_size, self.att_embedding_size * self.head_num],
dtype=tf.float32, initializer=tf.keras.initializers.TruncatedNormal(seed=self.seed+2))
if self.use_res:
self.W_res = self.add_weight(name="res", shape=[embedding_size, self.att_embedding_size * self.head_num],
dtype=tf.float32, initializer=tf.keras.initializers.TruncatedNormal(seed=self.seed+3))
super(InteractingLayer, self).build(input_shape)
def call(self, inputs):
# inputs (none, field_nums, embed_num)
querys = tf.tensordot(inputs, self.W_query, axes=(-1, 0)) # (None, field_nums, att_emb_size*head_num)
keys = tf.tensordot(inputs, self.W_key, axes=(-1, 0))
values = tf.tensordot(inputs, self.W_value, axes=(-1, 0))
# 多头注意力计算 按照头分开 (head_num, None, field_nums, att_embed_size)
querys = tf.stack(tf.split(querys, self.head_num, axis=2))
keys = tf.stack(tf.split(keys, self.head_num, axis=2))
values = tf.stack(tf.split(values, self.head_num, axis=2))
# Q * K, key的后两维转置,然后再矩阵乘法
inner_product = tf.matmul(querys, keys, transpose_b=True) # (head_num, None, field_nums, field_nums)
normal_att_scores = tf.nn.softmax(inner_product, axis=-1)
result = tf.matmul(normal_att_scores, values) # (head_num, None, field_nums, att_embed_size)
result = tf.concat(tf.split(result, self.head_num, ), axis=-1) # (1, None, field_nums, att_emb_size*head_num)
result = tf.squeeze(result, axis=0) # (None, field_num, att_emb_size*head_num)
if self.use_res:
result += tf.tensordot(inputs, self.W_res, axes=(-1, 0))
result = tf.nn.relu(result)
return result
Transformer块两个小细节:
-
参数初始化那个地方, 后面的seed一定要指明出参数来,不用seed=,结果导致训练有问题。
-
自注意力机制计算的时候,这里的多头计算处理方式, 把多个头分开,采用堆叠的方式进行计算(堆叠到第一个维度)。只有这样才能使得每个头与每个头之间的自注意力运算是独立不影响的。不这么做的话,最后得到的结果会含有当前单词在这个头和另一个单词在另一个头上的关联,这是不合理的。
上面自注意部分的输出结果与DNN或者Wide部分结合可以灵活多变,具体需要结合场景。
Q:自注意力里面的Q,K能用一个吗?类似于只用 Q Q Q ,算注意力的时候,直接 Q Q T Q Q^{T} QQT 。
A:关于这个问题,违背了当时设计自注意力的初哀,如果直接 Q Q T Q Q^{T} QQT ,那么得到的注意力矩阵是一个对称的矩阵
基于一个假设就是A特征对于B特征 的重要性,和B特征对于A的重要性是一致的,是不太符合常规的。所以直接用同一个矩阵,在表达能力上会受到限制。
参考文献:
- AutoInt: Automatic Feature Interaction Learning via Self-Attentive Neural Networks
- AutoInt:基于Multi-Head Self-Attention构造高阶特征
- https://github.com/zhongqiangwu960812/AI-RecommenderSystem