Attention is all you need论文原文地址
self-attention
attention的简要理解
attention机制在不同模型中的实现不尽相同,它假设输出对输入的不同部分依赖程度不同,例如image_caption任务中的图片描述,每个word对于图片中的关注部分是不同的。利用某种方法计算出输出对输入的权重系数,再对输入进行加权求和得到最终结果。
利用一个q向量与输入向量x依次做运算得到权重α,最后x经过加权求和得到y,q的个数由输出向量的个数确定。而计算α通常是由q和x直接做内积得到的,这里q是可学习的参数。
class Attention(nn.Module):
def __init__(self,x_dims,y_nums):
"""
x_nums:输入向量x的维数
y_nums:输出向量y的个数
"""
super().__init__()
self.Q = nn.Linear(x_dims,y_nums)
self.softmax = nn.Softmax(dim=1)
pass
def forward(self,x):
#x:[x_nums,x_dims] x_nums表示输出一个y需要考虑多少个x
alpha =self.Q(x).T#[y_nums,x_nums]
alpha =self.softmax(alpha)#[y_nums,x_nums]
y = torch.mm(alpha,x) #[y_nums,x_dims]
return y
下面这个例子表示我们输入了4个长度为4的向量x,想要通过加权得到3个输出向量y,alpha记录了每个输入所分配的权重 。
由attention到QKT
上面所介绍的attention机制学习的是label(输出)对不同输入的依赖,并不是输入之间的关系(y决定了对x的依赖)。在进行machine translation的任务时(transformer一开始被用到的任务中),当一句话作为一个sequence输入,显然,句子中间每个单词有着不同程度的关联,也就是说单词存在于一句话,乃至一整篇文档的语境之中。
那么如何使得每个单词能得到上下文信息呢?传统的方法是RNN,每个时间步读取一个单词以及上一个时间步的输入,来生成这一个时间步的输出。这种传统的做法缺点很多,首先一个时间步只能decode一个单词,无法做到并行处理,第二RNN无法做到真正上的长期依赖,而翻译任务或者文档分析任务恰恰需要这种特性。
《Attention is all you need》中提出的是叫self-attention的一种特殊的attention机制,在我查阅的论文资料里,几乎都是只给出了矩阵运算的式子,并没有详细说明这几个矩阵的作用以及必须需要的理由。下面我会一步步的阐述我理解的self-attention结构,以及涉及到的Q,K,T三个矩阵的意义。
在欧式空间中(向量之间的夹角可以被计算,可以做内积),两个向量的内积的公式为:
a
∙
b
=
∣
a
∣
∣
b
∣
c
o
s
θ
a \bullet b= |a||b|cos\theta
a∙b=∣a∣∣b∣cosθ
而二者同向时取最大值,互相垂直时为0,二者方向相反时小于0且最小。也就是说两个向量之间的点积在一定程度上可以代表二者之间的关联程度。
我们将上面图片中的
q
\bold q
q改成了
x
\bold x
x,这样一来计算的
α
\alpha
α就可以看成
x
1
\bold x_1
x1与
x
2
,
x
3
,
x
4
\bold x_2,\bold x_3,\bold x_4
x2,x3,x4之间的权重关系了,这种输入之间求权重关系的操作就被称为self-attention,翻译过来就是自注意力机制。
由于直接拿输入进行权重计算没有可学习的参数,所以需要将输入乘上一个可学习的矩阵
W
q
W^q
Wq得到query向量,由于每一个输入向量都要乘上这个矩阵,所以可以写成下面的矩阵形式:
Q
=
W
q
X
Q = W^qX
Q=WqX
这就是Q矩阵存在的必要性。现在我们已经将原始输入向量x映射成了query向量q了,那么可以直接计算向量之间的内积,然后得到
α
\alpha
α了吗?
很遗憾,不能直接这么做。假设我们现在需要将
S
h
e
i
s
a
b
e
a
u
t
i
f
u
l
g
i
r
l
She\space is \space a\space beautiful\space girl
She is a beautiful girl这句话翻译成中文,
b
e
a
t
i
f
u
l
beatiful
beatiful和
g
i
r
l
girl
girl这两个单词很大程度上由
s
h
e
she
she决定,而
s
h
e
she
she对这两个单词的依赖程度就没有那么高了。如果我们直接使用Q中的query向量计算权重会发现
x
1
对
x
2
x_1对x_2
x1对x2的依赖于
x
2
与
x
1
x_2与x_1
x2与x1的依赖是等价的了,这显然与人类语言的规则不吻合。所以我们还需要一个可学习矩阵
W
k
W^k
Wk来解决这种等价问题:
K
=
W
k
X
K = W^kX
K=WkX
现在看来
K
K
K这个矩阵也是必须存在的了,依靠
Q
K
T
QK^T
QKT以及一个Softmax操作得到最后的权重矩阵
A
A
A。
到这里我们可以先思考一下,好像直接拿着这个
A
A
A与
X
X
X相乘就能得到加权的结果了,从我现在整理的资料来看,V矩阵似乎不是必须存在的,但为了提高self-attention的拟合能力,这个V似乎不可或缺。
Transformer中的self-attention的描述
了解了上面的知识以后,我们来看下论文中的表述。
论文中强调了query和key有着相同的dimension,这是必须的,因为二者需要做内积就必须dimension相同,矩阵运算是并行的,所以self-attention的一次操作就能时所有向量获得上下文信息。
1
d
k
\frac {1}{\sqrt d_k}
dk1的出现是因为当
d
k
d_k
dk较大时,两个维度为
d
k
d_k
dk的向量做内积的结果可能很大,出现梯度爆炸现象。
import math
class SelfAttention(nn.Module):
def __init__(self,embed_dim,dk,dv):
#embed_dim词向量的长度
super().__init__()
self.Wq = nn.Linear(embed_dim,dk)
self.Wk = nn.Linear(embed_dim,dk)
self.Wv = nn.Linear(embed_dim,dv)
self.Wo = nn.Linear(dv,embed_dim) #将向量的维度转换回去
self.softmax = nn.Softmax(dim=2)
self.dk = dk
pass
def forward(self,t):
#[batch_size,sequence_len,embed_dim]
Q = self.Wq(t) #[batch_size,sequence_len,dk]
K = self.Wk(t) #[batch_size,sequence_len,dk]
V = self.Wv(t) #[batch_size,sequence_len,dv]
print('Q.shape:',Q.shape)
print('K.shape:',K.shape)
print('V.shape:',V.shape)
A = torch.bmm(Q,K.permute(0,2,1))
A = A / math.sqrt(self.dk)
A = self.softmax(A)#batch_size,sequence_len,sequence_len
print('A.shape',A.shape)
result = torch.bmm(A,V) #batch_size,sequence_len,dv
result = self.Wo(result)
return result
pass
Multi-Head attention
论文中提到仅仅使用一次self-attention效果没有并行的使用多次效果来的好。文中使用了8个
W
q
,
W
k
,
W
v
W^q,W^k,W^v
Wq,Wk,Wv并行计算8个结果,再将它们concatenate起来,用一个矩阵调节回原本的形状。
这是论文中的原图,我个人感觉这里V,K,Q三个字母的使用让我有点迷惑,我的理解是这里输入的都是X,经过linear变换以后得到V,K,Q,然后再进行Attention计算。如果直接使用Multi-Head self-attention的话,会导致参数量的成倍增加,论文中的做法是将每个x进行了切分,假设现在head数等于8,V,K,Q的分别为原先的1/8.所以要求维度必须能够整除head数,有关于这块若不清楚,可稍后查看代码实现部分。
class MultiHeadAttention(nn.Module):
def __init__(self,dm,dk,dv,h):
"""
dm:词向量的长度
dk:query,key的维度
dv:val的维度
h:被分成多少个head
"""
super().__init__()
self.Wq = nn.Linear(dm,dk)
self.Wk = nn.Linear(dm,dk)
self.Wv = nn.Linear(dm,dv)
self.Wo = nn.Linear(dv,dm)
self.softmax = nn.Softmax(dim=2)
self.h = h
self.d = math.sqrt(dk /h)
pass
def forward(self,t):
Q = self.Wq(t)#[batch_size,max_len,dk/h * h]
K = self.Wk(t)#[batch_size,max_len,dk/h * h]
V = self.Wv(t)#[batch_size,max_len,dv/h * h]
Qs = torch.chunk(Q,self.h,dim=2)#被拆分成了h个小矩阵h * [batch_size,max_len,dk/h]
Ks = torch.chunk(K,self.h,dim=2)#h * [batch_size,max_len,dk/h]
Vs = torch.chunk(V,self.h,dim=2)#h * [batch_size,max_len,dv/h]
result = list()
for i in range(self.h):
A = torch.bmm(Qs[i],Ks[i].permute(0,2,1))#[batch_size,max_len,max_len]
A = A / self.d
result.append(torch.bmm(A,Vs[i])) #[batch_size,max_len,dv/h]
result = torch.cat(result,dim=2)
return self.Wo(result) #经过multihead之后不改变输入的形状,但是取得了上下文的信息
pass
增加了遮罩层的MultiHeadAttention
class MaskAttention(nn.Module):
def __init__(self,dm,dk,dv,h):
"""
dm:词向量的长度
dk:query,key向量的长度
dv:value向量的长度
h:分成几个head
"""
super().__init__()
self.Wq = nn.Linear(dm,dk)
self.Wk = nn.Linear(dm,dk)
self.Wv = nn.Linear(dm,dv)
self.Wo = nn.Linear(dv,dm)
self.softmax = nn.Softmax(dim=2)
self.h = h
self.d = math.sqrt(dk/h)
pass
def forward(self,x,t):
Q = self.Wq(x)
K = self.Wk(x)
V = self.Wv(x)
Qs = torch.chunk(Q,self.h,dim=2)
Ks = torch.chunk(K,self.h,dim=2)
Vs = torch.chunk(V,self.h,dim=2)
result = list()
for i in range(self.h):
A = torch.bmm(Qs[i],Ks[i].permute(0,2,1))
A = A / self.d
for row in range(x.shape[1]):
for col in range(t+1,x.shape[1]):
A[:,row,col:] = -100000 #用这个来表示-∞,以e为底作为指数以后就很接近0了
A = self.softmax(A)
result.append(torch.bmm(A,Vs[i]))
result = torch.cat(result,dim=2)
return self.Wo(result)
这里是一条数据,假设sequence的长度是10,每个时间步将数据传入加了Mask以后的MultiHeadAttention,下面我可视化了每个时间步的A矩阵,每一张图都是热力图,颜色越深代表word之间的关联性越强。
Position-wised feed-forward network
本文中逐位置的前馈神经网络又称作FFN结构,这里的逐位置指的是,一个sequence中每一个位置的词向量。该结构对每一个位置都做了同样的变换。
如上图所示,最左边可以看成一个长度为3的序列,每个位置是长度为7的词向量,经过一个权重矩阵变形得到右边的三个行向量,可以发现每个位置的向量做的都是同一个矩阵的变换,所以被称为Position-wised.
class FFN(nn.Module):
def __init__(self,dm,dff):
"""
dff是ffn中隐藏单元个数,一般要求比dm大,原文中dm用的是512,dff用的是2048
"""
super().__init__()
self.fc1 = nn.Linear(dm,dff)
self.fc2 = nn.Linear(dff,dm)
self.relu = nn.ReLU()
pass
def forward(self,t):
t = self.fc1(t)
t = self.relu(t)
return self.fc2(t)
论文代码复现实战
本篇博文的参考代码来在Github上一个开源项目a-PyTorch-Tutorial-to-Machine-Translation,但是部分代码我为了理解起来更方便做了调整,并对代码做了详细注释。此外我对部分不常用的包做了拓展知识的分享,例如youtokentome这个用来实现BPE分词的包,国内外网站关于它的使用文档太少,但是它对机器翻译任务又很重要,所以我对它的使用方法做了较为详细的说明。
download the training data
我们使用的数据来自http://www.statmt.org/wmt14/translation-task.html,并不需要你直接到网站上下载,下面的程序会自动下载到指定文件夹。现在需要做的是对训练用到的数据有个简单的认知。
我们需要的是画线的三个文件,使用其中的德语和英语对应的部分,下面通过tarfile的实例对象的list()方法展现了每个压缩包中包含的数据
注意到第一个和第三个压缩文件都在training这个文件夹下面,所以我处理的时候需要把training这个文件目录拿掉。
现在总结下获取训练文件的步骤:
- 创建压缩文件的目录以及解压缩文件的目录
- 依次请求下载这三个压缩文件到压缩目录下
- 获得每个压缩文件tarfile实例对象,只保留下面的de-en的文件
- 把所有de-en文件解压到解压缩目录下
- 处理training文件夹下的文件,将它们直接move到解压缩目录下
import os,shutil,wget,tarfile
def download_data(data_folder):
train_urls = [
"http://www.statmt.org/wmt13/training-parallel-europarl-v7.tgz",
"https://www.statmt.org/wmt13/training-parallel-commoncrawl.tgz",
"http://www.statmt.org/wmt14/training-parallel-nc-v9.tgz"
]
#1.创建需要的压缩文件目录(tar files)以及解压缩目录(extracted files)
if not os.path.isdir(os.path.join(data_folder,'tar files')):
os.mkdir(os.path.join(data_folder,'tar files'))
if os.path.isdir(os.path.join(data_folder,'extracted files')):
shutil.rmtree(os.path.join(data_folder,'extracted files'))
os.mkdir(os.path.join(data_folder,'extracted files'))
for url in train_urls:
filename = url.split('/')[-1]
#2.将压缩文件下载到压缩目录中
if not os.path.exists(os.path.join(data_folder,'tar files',filename)):
print("Downloading %s..." % filename)
wget(url,os.path.join(data_folder,'tar files',filename))
print("Extracted %s..." % filename)
tar = tarfile.open(os.path.join(data_folder,'tar files',filename))
#3.将压缩文件中需要的留下
members = [m for m in tar.getmembers() if 'de-en' in m.path]
#4.解压缩到指定的解压缩目录中
tar.extractall(os.path.join(data_folder,'extracted files'),members=members)
dir_and_file = os.listdir(os.path.join(data_folder,'extracted files'))#列出解压缩目录下的文件夹名和文件名
paths = [path for path in dir_and_file if os.path.isdir(os.path.join(data_folder,'extracted files',path))]#将是文件目录的path拿出来
for path in paths:
#将源文件夹下的全部文件移动到指定文件夹下
for file in os.listdir(os.path.join(data_folder,'extracted files',path)):
shutil.move(os.path.join(data_folder,'extracted files',path,file),os.path.join(data_folder,'extracted files'))
os.rmdir(os.path.join(data_folder,'extracted files',path))#移动完文件夹里面的文件以后就可以把空文件夹删掉了
处理完的文件目录以及文件内容如上图所示,每一个文件中包含的是一句句语料,英文和的德文是一一对应的
youtokentome 模块的介绍
到目前为止,我们已经从指定网站将所需要的语料库下载并解压缩到了指定文件夹。下一步需要构建词典,然后将单词映射成对应的id值。machine translation任务成现在较常用的分词算法是BPE算法,它的分词单元不再是一个个单词,而是比单词层次更小的单元,有关于BPE算法的简要概述可以参考我的另一篇博客NLP中的BPE(byte pair encoding)分词算法。youtokentome这个包就是实现了BPE分词算法,它可以帮助我们对自己的语料库进行分词。
现在有两个文本,每个文本三句话,分别对应英文和中文。
#分别用一个列表来保存每个文件中的每一行内容
chinese = list()
english = list()
with open('./english.txt','r') as f:
english.extend(f.read().split('\n'))
with open('./chinese.txt','r') as f:
chinese.extend(f.read().split('\n'))
将两个文件中的内容用一个文件来保存,方便后面分词
with open('en-ch.txt','w') as f:
f.write('\n'.join(english+chinese))
import youtokentome
youtokentome.BPE.train(data='./en-ch.txt',vocab_size=80,model='./en-ch.model')
BPE.train主要接受三个参数,第一个是用作分词训练的模型,vocab_size是最后的字典大小,model表明最后用于分词解码和编码的文件放在什么路径。pad(padding)默认占用0号id,unk(unkown)默认占用1号id,bos(begin of sequence)默认占用2号id,eos(end of sequence)默认占用3号id
这是model文件的内容,我在第一次看到的时候还是比较惊异的,靠着几列数字就能编码和解码文件,一开始不懂解码的规则,便在国内外网站上查阅相关资料,但遗憾的是并没有收获。
后来经过自己的猜测和论证,终于弄清楚了该model文件的解码规则,在此和大家分享,如果不感兴趣后面相关内容可跳过。
文件的第一行表达的信息是:unique character和byte pair的个数各是多少。unique character指的是单个不重复的英语字母和汉字,2 ~ 44行(共43行)记录的就是每个unique character的unicode编码值以及对应的id,比如第二行的20219就是汉字
任
\bold 任
任 的unicode值,也只有unicode才能表达各国语言的文字。
45 ~ 80行记录的是字符对,或者字符序列对。比如第45行的4 9 48,他代表的意思是48对应的字符对由id号4和9对应的字符组成。id 4对应的是unicode值是9601,相应的字符是 _(下划线代表的是一个单词的开始) ,id 9对应的unicode是108,它对应的是英文字符 l。所以id 48号对应的就是 _I.
在将一段文本转成id的时候,先单个字符换成对应的id,如果出现4,9则用48代替,最后使得encode的结果长度越短越好。
Prepareing the data
- 将三部分来源的数据进行整合,需要合并成一个文件,进行BPE分词训练得到model文件
- 分别对德语和英语文件进行编码,将一个个word变成id值
from tqdm import tqdm
import os,codecs,youtokentome
def prepare_data(data_folder,min_length=3,max_length=100,max_length_ratio=1.5,retain_case=True):
"""
data_folder:目标文件夹的路径
min_length:一句语料被encode后的长度应该比它大
max_length:一句语料被encode后的长度应该比它小
max_length_ratio:两个语言的句子长度比例不应该比它大
retain_case:是否保留大小写信息
"""
#保存句子的两个列表
english = list()
german = list()
files = os.listdir(os.path.join(data_folder,'extracted files'))
#下面必须使用codecs,要不然可能因为数据编码不一致的问题导致英语和德语的长度不一样
for file in files:
if file.endswith('.en'):
with codecs.open(os.path.join(data_folder,'extracted files',file),'r',encoding='utf-8') as f:
if retain_case:
english.extend(f.read().split('\n'))
else:
english.extend(f.read().lower().split('\n'))
if file.endswith('.de'):
with codecs.open(os.path.join(data_folder,'extracted files',file),'r',encoding='utf-8') as f:
if retain_case:
german.extend(f.read().split('\n'))
else:
german.extend(f.read().lower().split('\n'))
assert len(german) == len(english)
#上面的代码中,我们已经将所有的语料加载到了内存中,英文和德文被分别保存到了两个列表中
with codecs.open(os.path.join(data_folder,'train.en'),'w',encoding='utf-8') as f:
f.write('\n'.join(english))
with codecs.open(os.path.join(data_folder,'train.de'),'w',encoding='utf-8') as f:
f.write('\n'.join(german))
with codecs.open(os.path.join(data_folder,'train.ende'),'w',encoding='utf-8') as f:
f.write('\n'.join(english+german))
del english,german #这一步是为了释放内存,为后面训练BPE分词做准备
youtokentome.BPE.train(data=os.path.join(data_folder,'train.ende'),
vocab_size=37000,
model=os.path.join(data_folder,'bpe.model'))
bpe = youtokentome.BPE(os.path.join(data_folder,'bpe.model')) #将模型加载进来
with codecs.open(os.path.join(data_folder,'train.en'),'r',encoding='utf-8') as f:
english = f.read().split('\n')
with codecs.open(os.path.join(data_folder,'train.de'),'r',encoding='utf-8') as f:
german = f.read().split('\n')
pairs = list()
for en,de in tqdm(zip(english,german),total=len(english)):
en_tok = bpe.encode(en,output_type=youtokentome.OutputType.ID)
de_tok = bpe.encode(de,output_type=youtokentome.OutputType.ID)
en_tok_len = len(en_tok)
de_tok_len = len(de_tok)
if min_length < en_tok_len < max_length and \
min_length < de_tok_len < max_length and \
1 / max_length_ratio < en_tok_len / de_tok_len < max_length_ratio:
pairs.append((en,de))
else:
continue
english,german = zip(*pairs)
os.remove(os.path.join(data_folder,'train.en'))
os.remove(os.path.join(data_folder,'train.de'))
os.remove(os.path.join(data_folder,'train.ende'))
with codecs.open(os.path.join(data_folder,'train.en'),'w',encoding='utf-8') as f:
f.write('\n'.join(english))
with codecs.open(os.path.join(data_folder,'train.de'),'w',encoding='utf-8') as f:
f.write('\n'.join(german))