位置编码
Bert问世后瞬间引爆了NLP领域,同时也让Transformer火了起来,Transformer中特征提取的方式不是传统的CNN,RNN等,而是用attention的形式,这种模式被用在AI的各个领域中,包括CV和语音等。关于attention和transformer的计算原理在文章中已经讲解过,不再赘述,具体可参考:
https://zhuanlan.zhihu.com/p/231631291
attention提取特征的效果非常好,可以非常有效的提取到上下文的信息,但是在NLP中会有个问题:attention提取特征的时候,当前这个字对上下文的其他字的关联性可以很好的体现出来,但是其他字的位置在哪里都可以,在这个字的前面、后面都可以,间隔的距离也没有要求。
但其实这跟我们平时表达的语言肯定是矛盾的,于是在Transformer中加入了位置编码。
目前位置编码有两种方式:函数式和参数式
本文分成三类讲解:
1、绝对位置编码-BERT(学习位置编码)
2、正弦位置编码
3、相对位置编码-NEZHA
4、(处理超文本-层次位置编码)
对每一种都进行的讲解,并在代码中详细加了注释!
绝对位置编码-BERT
BERT使用的是训练出来的绝对位置编码,这种编码方式简单直接,效果也不错。
这种方法和生成词向量的方法相似,先初始化位置编码,再放到预训练过程中,训练出每个位置的位置向量。
关于该方法的代码如下,用Keras写的,参考苏剑林老师的bert4keras中的代码
from keras.layers import Layer
import keras.backend as K
from keras import initializers
import tensorflow as tf
class PositionEmbedding(Layer):
"""定义可训练的位置Embedding
"""
def __init__(
self,
input_dim,
output_dim,
merge_mode='add',
hierarchical=None,
embeddings_initializer='zeros',
custom_position_ids=False,
**kwargs
):
super(PositionEmbedding, self).__init__(**kwargs)
self.input_dim = input_dim # 输入维度max_position
self.output_dim = output_dim # 输出维度embedding_size,bert中用的是768
self.merge_mode = merge_mode # add模式或者mul模式
self.hierarchical = hierarchical
self.embeddings_initializer = initializers.get(embeddings_initializer)
self.custom_position_ids = custom_position_ids
def build(self, input_shape):
super(PositionEmbedding, self).build(input_shape)
self.embeddings = self.add_weight(
name='embeddings',
shape=(self.input_dim, self.output_dim),
initializer=self.embeddings_initializer
) # 初始化待训练的位置编码权重
def call(self, inputs):
"""如果custom_position_ids,那么第二个输入为自定义的位置id
"""
input_shape = K.shape(inputs)
batch_size, seq_len = input_shape[0], input_shape[1]
# 自己输入位置编码及其位置id
if self.custom_position_ids:
inputs, position_ids = inputs
if K.dtype(position_ids) != 'int32':
position_ids = K.cast(position_ids, 'int32')
else:
# 得到位置编码id 加了[None]变成两维的 [[0,1,2,...,seq_len]]
position_ids = K.arange(0, seq_len, dtype='int32')[None]
if self.hierarchical:
alpha = 0.4 if self.hierarchical is True else self.hierarchical
embeddings = self.embeddings - alpha * self.embeddings[:1]
embeddings = embeddings / (1 - alpha)
embeddings_x = K.gather(embeddings, position_ids // self.input_dim)
embeddings_y = K.gather(embeddings, position_ids % self.input_dim)
pos_embeddings = alpha * embeddings_x + (1 - alpha) * embeddings_y
else:
# 如果是自己输入位置编码,就用位置id读取相应的位置编码
if self.custom_position_ids:
pos_embeddings = K.gather(self.embeddings, position_ids)
else:
# 直接拿初始化的位置编码权重
pos_embeddings = self.embeddings[None, :seq_len]
# add模式直接把原有特征和位置编码相加即可
if self.merge_mode == 'add':
return inputs + pos_embeddings
# mul模式是把原有特征和位置编码对应相乘
elif self.merge_mode == 'mul':
return inputs * pos_embeddings
else:
if not self.custom_position_ids:
pos_embeddings = K.tile(pos_embeddings, [batch_size, 1, 1])
# 如果不属于上述两种模式,则用concat的形式
return K.concatenate([inputs, pos_embeddings])
def compute_output_shape(self, input_shape):
if self.custom_position_ids:
input_shape = input_shape[0]
if self.merge_mode in ['add', 'mul']:
return input_shape
else:
return input_shape[:2] + (input_shape[2] + self.output_dim,)
def get_config(self):
config = {
'input_dim': self.input_dim,
'output_dim': self.output_dim,
'merge_mode': self.merge_mode,
'hierarchical': self.hierarchical,
'embeddings_initializer':
initializers.serialize(self.embeddings_initializer),
'custom_position_ids': self.custom_position_ids,
}
base_config = super(PositionEmbedding, self).get_config()
return dict(list(base_config.items()) + list(config.items()))
正弦位置编码
使用绝对位置编码,不同位置对应的位置编码固然不同,但是位置1和位置2的距离,比位置2和位置5的距离更近;而位置1和位置2的距离,和位置3和位置4的距离都只相差1;而在BERT中通过学习位置编码的方式,位置之间是没有约束关系的,我们能做的就只是期待他能够学到并理解这些位置的相对关系。
所以可以用以下的方式来表达约束位置编码:正弦位置编码和相对位置编码正弦这一类的参数式位置编码中涉及两个概念:一个是距离,另一个是维度所以在涉及计算公式的时候,每个字之间按顺序给他id,用来代表着距离,就是pos这个词参数;另一个是维度,同一个字中不同维度的特征信息,可以用sin和cos的方式计算。这种方法是使用不同频率的正弦、余弦函数生成,然后再和对应位置的词向量相加
计算公式如下:
其中pos表示对应输入的位置,表示的是seq_len这个维度上;i表示的是维度,表示的是768个。奇偶相配合
class SinusoidalPositionEmbedding(Layer):
"""定义Sin-Cos位置Embedding
"""
def __init__(
self, output_dim, merge_mode='add', custom_position_ids=False, **kwargs
):
super(SinusoidalPositionEmbedding, self).__init__(**kwargs)
self.output_dim = output_dim
self.merge_mode = merge_mode
self.custom_position_ids = custom_position_ids
def call(self, inputs):
"""如果custom_position_ids,那么第二个输入为自定义的位置id
"""
input_shape = K.shape(inputs)
batch_size, seq_len = input_shape[0], input_shape[1]
if self.custom_position_ids:
inputs, position_ids = inputs
else:
# 得到位置编码id 加了[None]变成两维的 [[0,1,2,...,seq_len]]
position_ids = K.arange(0, seq_len, dtype=K.floatx())[None]
# 根据公式开始计算
# 取一半的,方便2i的计算
indices = K.arange(0, self.output_dim // 2, dtype=K.floatx())
# 对前一个参数x,取后一个参数y的平方,x^y,即10000^(2i/dim)
indices = K.pow(10000.0, -2 * indices / self.output_dim)
# shape=(btz, seq_len, dim)
pos_embeddings = tf.einsum('bn,d->bnd', position_ids, indices)
pos_embeddings = K.concatenate([
K.sin(pos_embeddings)[..., None],
K.cos(pos_embeddings)[..., None]
])
# [...,None]会在最后一维增加一维,把每个值用[]包起来
# 比如a = K.arange(0, 10) 本来输出的是:[0 1 2 3 4 5 6 7 8 9];a = K.arange(0, 10)[..., None]变成了[[0] [1] [2] [3] [4] [5] [6] [7] [8] [9]]
# 同K.expand_dim(pos_embeddings, -1)的效果
# 重新reshape成shape=(btz, seq_len, dim)
pos_embeddings = K.reshape(
pos_embeddings, (-1, seq_len, self.output_dim)
)
if self.merge_mode == 'add':
return inputs + pos_embeddings
elif self.merge_mode == 'mul':
return inputs * pos_embeddings
else:
if not self.custom_position_ids:
pos_embeddings = K.tile(pos_embeddings, [batch_size, 1, 1])
return K.concatenate([inputs, pos_embeddings])
def compute_output_shape(self, input_shape):
if self.custom_position_ids:
input_shape = input_shape[0]
if self.merge_mode in ['add', 'mul']:
return input_shape
else:
return input_shape[:2] + (input_shape[2] + self.output_dim,)
def get_config(self):
config = {
'output_dim': self.output_dim,
'merge_mode': self.merge_mode,
'custom_position_ids': self.custom_position_ids,
}
base_config = super(SinusoidalPositionEmbedding, self).get_config()
return dict(list(base_config.items()) + list(config.items()))
相对位置编码
相对位置编码的代表作就是NEZHA
参数式训练会受到句子长度的影响,bert起初训练的句子最长为512,如果只训练到128长度的句子,那在128—512之间的位置参数就无法获得,所以必须要训练更长的预料来确定这一部分的参数
在NAZHA中,距离和维度都是用正弦函数导出来的,并且在模型训练期间也是固定的。
class RelativePositionEmbedding(Layer):
"""相对位置编码
来自论文:https://arxiv.org/abs/1803.02155
"""
def __init__(
self, input_dim, output_dim, embeddings_initializer='zeros', **kwargs
):
super(RelativePositionEmbedding, self).__init__(**kwargs)
self.input_dim = input_dim # 129
self.output_dim = output_dim # attention_head_size每一头的维度 768/12=64
self.embeddings_initializer = initializers.get(embeddings_initializer)
def build(self, input_shape):
super(RelativePositionEmbedding, self).build(input_shape)
# 初始化待训练的位置编码权重
self.embeddings = self.add_weight(
name='embeddings',
shape=(self.input_dim, self.output_dim),
initializer=self.embeddings_initializer,
)
def call(self, inputs):
# 根据位置id获取位置编码 每一种的不一样,比如位置id=1的和位置id=-1的就不一样,读取进来,
pos_ids = self.compute_position_ids(inputs)
return K.gather(self.embeddings, pos_ids) # 输出的时候需要是(btz,seq_len,dim)
def compute_position_ids(self, inputs):
q, v = inputs # [x, x]
# 计算位置差
# 一维[0,1,...,q_seq_len]
q_idxs = K.arange(0, K.shape(q)[1], dtype='int32')
# [[0] [1] [2] [3] ... [q_seq_len]]
q_idxs = K.expand_dims(q_idxs, 1)
v_idxs = K.arange(0, K.shape(v)[1], dtype='int32')
# [[0,1,...,v_seq_len]]
v_idxs = K.expand_dims(v_idxs, 0)
pos_ids = v_idxs - q_idxs
'''以q_seq_len=v_seq_len=9为例:
[[ 0 1 2 3 4 5 6 7 8 9]
[-1 0 1 2 3 4 5 6 7 8]
[-2 -1 0 1 2 3 4 5 6 7]
[-3 -2 -1 0 1 2 3 4 5 6]
[-4 -3 -2 -1 0 1 2 3 4 5]
[-5 -4 -3 -2 -1 0 1 2 3 4]
[-6 -5 -4 -3 -2 -1 0 1 2 3]
[-7 -6 -5 -4 -3 -2 -1 0 1 2]
[-8 -7 -6 -5 -4 -3 -2 -1 0 1]
[-9 -8 -7 -6 -5 -4 -3 -2 -1 0]]
相对位置编码就比较简单的用这种差几位数来表示相对位置
'''
# 后处理操作
max_position = (self.input_dim - 1) // 2
'''
K.clip:逐元素clip,将pos_ids中超出(-max_position, max_position)范围的数强制变为边界值
1、作者假设精确的相对位置编码在超出了一定距离之后是没有必要的
2、截断最大距离使得模型的泛化效果好,可以更好的generalize到没有在训练阶段出现过的序列长度上
比如上面的例子中,截到(-4,4)之间为:
[[ 0 1 2 3 4 4 4 4 4 4]
[-1 0 1 2 3 4 4 4 4 4]
[-2 -1 0 1 2 3 4 4 4 4]
[-3 -2 -1 0 1 2 3 4 4 4]
[-4 -3 -2 -1 0 1 2 3 4 4]
[-4 -4 -3 -2 -1 0 1 2 3 4]
[-4 -4 -4 -3 -2 -1 0 1 2 3]
[-4 -4 -4 -4 -3 -2 -1 0 1 2]
[-4 -4 -4 -4 -4 -3 -2 -1 0 1]
[-4 -4 -4 -4 -4 -4 -3 -2 -1 0]]
'''
pos_ids = K.clip(pos_ids, -max_position,
max_position)
pos_ids = pos_ids + max_position # shape=(q_seq_lenv, v_seq_len)
return pos_ids
def compute_output_shape(self, input_shape):
return (None, None, self.output_dim)
def compute_mask(self, inputs, mask):
return mask[0]
def get_config(self):
config = {
'input_dim': self.input_dim,
'output_dim': self.output_dim,
'embeddings_initializer':
initializers.serialize(self.embeddings_initializer),
}
base_config = super(RelativePositionEmbedding, self).get_config()
return dict(list(base_config.items()) + list(config.items()))
旋转位置编码:
主要是下面两篇文章