Transformer


Transformer是在" Attention is All You Need"提出来的,是一个基于attention(自注意力机制)结构来处理序列相关问题的模型。

Transfomer在很多不同的nlp任务中取得了成功,例如:文本分类、机器翻译等。Tranformer没有使用CNN或者RNN的结构,完全基于注意力机制,自动捕捉输入序列不同位置的关系,擅长处理长文本序列信息,并且该模型可以高度并行工作,训练速度较快。

尽管Transformer最初是应用于序列到序列的学习文本数据,但是现在已经推广到了各种现代的深度学习中,例如语言、视觉、语音和强化学习等领域。

首先导入所用的所有的包:

import torch
from torch import nn
import math
from d2l.torch import d2l
import torch.nn.functional as F
import os
import collections
from torch.utils import data
import math
%matplotlib inline

1、模型结构

将Transformer整个模型视为一个黑盒,以机器翻译为例,它接受一种语言的句子,然后将其翻译成其他语言的句子作为输出。

img

再更深入一些看,Transformer是编码器-解码器结构的实例,它也是由编码器,解码器以及它们之间的连接组成。

img

而transformer中的编码器-解码器不是简单的一层,而是由多层的编码器-解码器组合起来的。在原论文中编码组件是由六个编码器首尾相接组成,解码组件也是由六个解码器堆叠而成。所有编码器的结构是相同的,但是并不共享参数,同样的,所有的编码器也是结构相同不共享参数。

img

编码器-解码器结可以使用各种网络,如CNN,RNN,LSTM等,而transformer的编码器-解码器的并不使用CNN,RNN这些结构,而是依赖自注意力机制。下面揭开解码器-编码器外面的黑盒,看看transformer的编码器和解码器中使用了什么结构:

img

从上图可以看出,编码器主要有两层组成:多头注意力和前馈神经网络,每一层的输出与输入进行残差连接并进行层归一化。解码器与编码器相比比较复杂一点,有三层组成:带掩码的多头注意力,多头与注意力(与编码器有点不一样)和前馈神经网络组成,同样的,每一层的输出与输入进行残差连接并进行层归一化。编码器和解码器的输入都经过了词嵌入和位置嵌入。现在看这些结构可能会很陌生,下面按照如下的流程对transformer的每个结构以及这些结构如何工作进行介绍:

  1. 模型结构
  2. 自注意力 ⟶ \longrightarrow 缩放点积注意 ⟶ \longrightarrow 多头注意力
  3. 词嵌入(Embedding) & 位置嵌入(Input Embedding)
  4. 基于位置的前馈神经网络
  5. 残差连接 & 层归一化(Layer Normalization)
  6. 编码器
  7. 解码器
  8. 编码器与解码器
  9. 模型训练&预测
  10. 代码整合

2、自注意力机制(Self-attention)

Self-attention的输入是一整个句子序列,这里的句子序列可以为每一个单词的词向量表示也可以是网络内部的隐藏向量。输入几个向量就会输出几个向量,而输出的每个向量都是考虑了一整个句子的信息。在self-attention中每一个输出都是考虑了所有的输入计算出来的。如下图所示,图中的每一个输出b都是考虑了所有的a才得到的。

img

那如何计算输出哪?

首先每一个输入向量都需要与所有的输入向量计算注意力分数,然后对所有输入向量的注意力分数计算softmax得到注意力权重,注意力权重与每一个输入的 v a l u e value value值(输入与权重矩阵( W v W_v Wv)相乘得到)进行相乘就可以得到最终的输出。这是计算的整个过程,可能理解起来比较困难,下面一步一步看是如何计算的。

2.1、注意力权重矩阵计算

首先是计算输入向量与所有的输入向量计算注意力分数$\alpha
。 以 第 一 个 输 入 向 量 为 例 , 如 下 图 所 示 , 其 中 的 。以第一个输入向量为例,如下图所示,其中的 q,k 分 别 代 表 输 入 向 量 分别代表输入向量 a 的 查 询 ( 的查询( (query ) 和 键 ( )和键( )(key ) , 这 两 个 值 是 由 输 入 向 量 ),这两个值是由输入向量 )a 分 别 与 两 个 权 重 矩 阵 ( 分别与两个权重矩阵( (Wk$和$Wq ) 相 乘 得 到 的 。 然 后 当 前 向 量 )相乘得到的。然后当前向量 )a_1 的 查 询 值 的查询值 q^1 会 与 所 有 的 输 入 向 量 的 键 值 会与所有的输入向量的键值 k 进 行 相 关 计 算 ( 这 里 的 计 算 方 式 常 用 的 有 进行相关计算(这里的计算方式常用的有 (Dot-product 和 和 Additive 两 种 , 会 在 下 面 介 绍 ) , 就 得 到 了 当 前 向 量 两种,会在下面介绍),就得到了当前向量 )a_1 与 所 有 的 输 入 向 量 之 间 的 注 意 力 分 数 与所有的输入向量之间的注意力分数 \alpha , 然 后 对 这 些 注 意 力 分 数 计 算 s o f t m a x , 得 到 和 为 1 的 注 意 力 权 重 向 量 ,然后对这些注意力分数计算softmax,得到和为1的注意力权重向量 softmax1\alpha’$。
img

对于其他的输入向量的注意力权重向量计算过程也是一样的,然后将这些注意力权重向量拼在一起就得到了一个注意力权重矩阵,如下图所示。

img

2.2、masked softmax

然而在真实情况中,输入的向量可能是经过填充的,例如要求的输入序列的长度为5,而真实的序列长度只有4,那么就需要对序列填充一个填充符向量,然而这个填充符向量是没有意义的。为了只得到有意义的向量的注意力矩阵,因此在对注意力分数向量计算softmax之前,对所有有填充符向量参与计算的注意力分数用一个很小的值进行遮挡(mask),那么在经过softmax计算后的填充符向量对应的注意力分数就为0,这样就实现了不对填充符向量计算注意力分数。下面使用代码实现。

#在序列中屏蔽不相关的项
#X中超过valid_len的部分都会被遮住
def sequence_mask(X,valid_len,value = 0):
  maxlen = X.size(1)
  mask = torch.arange((maxlen),dtype = torch.float32,device = X.device)[None,:] < valid_len[:,None]
  X[~mask] = value
  return X

#通过在最后一个轴上掩蔽元素来执行softmax操作
def masked_softmax(X,valid_lens):
   # X:3D张量,valid_lens:1D或2D张量
  if valid_lens is None:
    return nn.functional.softmax(X,dim = -1)
  else:
    shape = X.shape
    if valid_lens.dim() == 1:
      valid_lens = torch.repeat_interleave(valid_lens,shape[1])
    else:
      valid_lens = valid_lens.reshape(-1)
    X = sequence_mask(X.reshape(-1,shape[-1]),valid_lens,value = -1e6)
    return nn.functional.softmax(X.reshape(shape),dim = -1)

按照上面的假设,一个序列真实长度为4,而真实长度为5,那么序列最后一个是填充的,假设在经过 D o t − p r o d u c t Dot-product Dotproduct A d d i t i v e Additive Additive计算后得到的注意力分数向量为[2,4,5,7,1],那么就需要对最后一个分数1进行遮盖,得到softmax后的注意力分数向量[0.0057, 0.0418, 0.1135, 0.8390, 0.0000]。

X = torch.tensor([2.,4.,5.,7.,1.]).reshape(1,1,5)
ss = torch.tensor([4])
print(masked_softmax(X,ss))
tensor([[[0.0057, 0.0418, 0.1135, 0.8390, 0.0000]]])

2.3、计算自注意力输出

在计算完注意力矩阵之后,还需要使用注意力矩阵与 v v v(value)进行相乘才能得到最终每个输入向量对应的输出 b b b,其中 v v v由输入向量与权重向量 W v W^v Wv相乘得到。

img

现在就完成了self-attention的计算,下面再整体看一下它是如何在矩阵的层面进行计算的。这里以 D o t − p r o d u c t Dot-product Dotproduct为例。首先解释下上面提到的输入向量是什么吧,以英文句子为例,输入序列是多个英文单词组成的句子,当然不能直接输入文本,需要把单词转换成词向量,因此上面提到的输入向量就是一个单词的词向量,或者是上一个隐藏层输出向量。将一整个序列的的输入向量分别与权重矩阵 W q , W k , W v W^q,W^k,W^v Wq,Wk,Wv相乘,得到 Q Q Q查询(query)、 K K K键(key)、 V V V值(value), Q Q Q K K K做矩阵乘法得到的结果经过 s o f t m a x softmax softmax处理就得到了注意力权重矩阵,用得到的注意力权重矩阵与 V V V做乘法运算就得到了最终的self-attention的输出 O O O

在每一个self-attention中权重矩阵 W q , W k , W v W^q,W^k,W^v Wq,Wk,Wv是共享的。其实在实现过程中与权重矩阵相乘就是一个将输入传进一个没有偏置的线性网络。

img

2.4、Dot-product 和 Additive

上面介绍注意力分数计算时采用的方法有 D o t − p r o d u c t Dot-product Dotproduct A d d i t i v e Additive Additive两种,下面介绍这两种分别是如何计算的。

2.4.1、Additive

一般来说,当键和查询是不同长度的特征向量时,可以使用加性注意力作为评分函数,如下图所示。给定查询$q 和 键 和键 k $(就是不同的输入向量,还未与权重矩阵进行计算),加性注意力(additive attention)的评分函数为:
α = W ⊤ t a n h ( W q q + W k k ) \alpha=W^{\top}tanh(W^qq+W^kk) α=Wtanh(Wqq+Wkk)
W W W代表一个权重矩阵,相当于一个没有偏置的单层MLP。

将键和查询(现在是经过输入与权重计算得到的)连接起来后使用tanh作为激活函数处理,然后输入到一个多层感知机 W W W中,再经过softmax处理得到注意力矩阵,使用注意力矩阵与value(由输入向量与权重矩阵 W v W^v Wv相乘得到)相乘得到最终的self-attention输出。代码如下:

#加性注意力
class AdditiveAttention(nn.Module):
    def __init__(self,key_size,query_size,num_hiddens,dropout,**kwargs):
        super(AdditiveAttention, self).__init__(**kwargs)
        self.W_k = nn.Linear(key_size,num_hiddens,bias = False)
        self.W_q = nn.Linear(query_size, num_hiddens, bias=False)
        self.W = nn.Linear(num_hiddens, 1, bias=False)
        self.dropout = nn.Dropout(dropout)
    def forward(self,queries,keys,values,valid_lens):
        queries,keys = self.W_q(queries),self.W_k(keys)

        #维度扩展后
        #`queries`的形状:(`batch_size`,查询个数,1,`num_hidden`)
        #`keys`的形状:(`batch_size`,1,键的个数,`num_hidden`)
        #使用广播方式求和
        features = queries.unsqueeze(2) + keys.unsqueeze(1)
        features = torch.tanh(features)

        # self.w_v仅有一个输出,因此从形状中移除最后那个维度。
        # scores的形状:(batch_size,查询的个数,“键-值”对的个数)
        scores = self.W(features).squeeze(-1)
        self.attention_weights = masked_softmax(scores,valid_lens)
        # values的形状:(batch_size,“键-值”对的个数,值的维度)
        #注意力权重与value相乘得到最终结果
        return torch.bmm(self.dropout(self.attention_weights),values)

下面用一个例子来演示上面的代码,其中查询、键和值的形状为(批量大小,步数或词元序列长度,特征大小),实际输入为(2,1,20)、(2,10,2)、(2,10,4)。使用加性注意力得到的self-attention的输出的形状为(批量大小,查询的步数,值的维度),因此得到的最终输出形状为(2,1,4)

queries, keys = torch.normal(0, 1, (2, 1, 20)), torch.ones((2, 10, 2))
# values的小批量,两个值矩阵是相同的
values = torch.arange(40, dtype=torch.float32).reshape(1, 10, 4).repeat(
    2, 1, 1)
valid_lens = torch.tensor([2, 6])

attention = AdditiveAttention(key_size=2, query_size=20, num_hiddens=8,
                              dropout=0.1)
attention.eval()
attention(queries, keys, values, valid_lens)
tensor([[[ 2.0000,  3.0000,  4.0000,  5.0000]],

        [[10.0000, 11.0000, 12.0000, 13.0000]]], grad_fn=<BmmBackward0>)

2.4.2、Dot-product

键和值之间还可以使用点乘的方法计算结果,这就要求键和值的特征向量的长度也是一样的,它的计算方式如下图所示。输入分别与权重矩阵 W q W^q Wq W k W^k Wk相乘得到查询 q q q和键 k k k, q q q k k k相乘得到注意力分数向量,然后将得到的注意力分数向量经过softmax处理得到注意力权重向量,然后在于value值相乘得到最终的输出结果。注意力分数计算为:
α ( q , k ) = q k ⊤ \alpha(q,k) = qk^{\top} α(q,k)=qk

img

在transformer的self-attention使用的方法与 D o t − p r o d u c t Dot-product Dotproduct方法还是有一点区别,transfomer使用的是(缩放点积注意力) S c a l e d    D o t − P r o d u c t Scaled\;Dot-Product ScaledDotProduct,就是在将 k k k q q q的点积除以 d k \sqrt{d_k} dk d k d_k dk代表 k k k的向量长度( q q q k k k的向量长度是一样的),然后再跟值相乘,公式如下:
A t t e n t i o n ( Q , K , V ) = s o f t m a x ( Q K ⊤ d k ) V Attention(Q,K,V)=softmax(\frac{QK^{\top}}{\sqrt{d_k}})V Attention(Q,K,V)=softmax(dk QK)V
k k k q q q的向量长度比较小的时候,除或不除 d k \sqrt{d_k} dk 都是可以的。但是当长度比较大的时候,就是两个向量比较长的时候,它们之间做点积,就可能会导致结果值有些比较小,有些比较大,那么再进行softmax的时候,比较大的值会接近1,而比较小的值则会接近0,导致它们之间的差距比较大。这种向两端靠拢的情况会使得计算梯度的时候值比较小。transformer中 d k d_k dk的值一般为512,是比较大的,因此除 d k \sqrt{d_k} dk 就可以使得每个注意力分数在经过softmax后不会出现向两端靠拢的情况,让梯度更加稳定。

下面为缩放点积注意( S c a l e d D o t − P r o d u c t Scaled Dot-Product ScaledDotProduct)的代码实现

class DotProductAttention(nn.Module):
    def __init__(self,dropout,**kwargs):
        super(DotProductAttention, self).__init__(**kwargs)
        self.dropout = nn.Dropout(dropout)

    # queries的形状:(batch_size,查询的个数,d)
    # keys的形状:(batch_size,“键-值”对的个数,d)
    # values的形状:(batch_size,“键-值”对的个数,值的维度)
    # valid_lens的形状:(batch_size,)或者(batch_size,查询的个数)
    def forward(self,queries,keys,values,valid_lens = None):
        d = queries.shape[-1]
        scores = torch.bmm(queries,keys.transpose(1,2)) / math.sqrt(d)
        self.attention_weights = masked_softmax(scores,valid_lens)
        return torch.bmm(self.dropout(self.attention_weights),values)

下面测试一下代码,上述代码的输入是输入向量与权重矩阵运算得到的 q q q, k k k, v v v值。query有一个为[1,1,2,2],key-value对有三个为[1,0,1,1]-[1,2,6,1],[2,2,1,0]-[3,2,4,1],[2,1,1,1]-[2,1,4,1],下面就用上面的代码计算一个输入向量的key与三个输入向量key-value的self-attention的输出。计算过程如下图所示:

img

query = torch.tensor([1.,1.,2.,2.]).reshape(1,1,4)
key = torch.tensor([[1.,0.,1.,1.],[2.,2.,1.,0.],[2.,1.,1.,1.]]).reshape(1,3,4)
value = torch.tensor([[1.,2.,6.,1.],[3.,2.,4.,1.],[2.,1.,4.,1.]]).reshape(1,3,4)
attention = DotProductAttention(dropout=0)
attention.eval()
attention(query, key, value)
tensor([[[2.1209, 1.4935, 4.3726, 1.0000]]])

2.5、回顾自注意力计算过程

以上就是transformer中self-attention的计算过程。下面在结合图像过一遍计算的过程。

第一步,根据编码器的输入向量,生成三个向量:查询(query)向量、键(key)向量、值(value)向量,生成方法是输入向量分别乘以三个权重矩阵,这三个权重矩阵在训练过程中进行学习。

不是每个词向量独享3个权重矩阵,而是所有的输入共享三个权重矩阵。

从下图中可以看出query、key、value向量的维度要比输入词向量的维度要小,这是为了后面提出的多头注意力,在transformer中输入向量的维度为512,有8个头,那么query、key、value向量的维度就要缩小成512/8=64维。下图没有画那么多维,只是通过维度变小说明这一点。

img

第二步,计算注意力分数,对“Thinking Matchines”这句话,对“Thinking”(pos#1)计算注意力分数。我们需要计算每个词与“Thinking”的注意力分数,这个分数决定着编码“Thinking”时,每个输入词需要集中多少关注度关注它。

计算过程为,通过“Thinking”对用的query向量与所有词的key向量相乘得到,第一个分数为 q 1 q_1 q1 k 1 k_1 k1的乘积,第二个分值为 q 1 q_1 q1 k 2 k_2 k2的乘积。

img

第三步和第四步,除以8( d k \sqrt{d_{k}} dk ),这个作用是使得经过softmax操作后的值不会出现两极分化,使得梯度更稳定。然后再经过softmax使得全为正数且和为1.

softmax分数表示每个词对当前位置词的关注程度。
img

第五步,将softmax后的注意力权重值与value值按位相乘。这样做可以提高有关注度词的value值,降低没有关注度词的value值。

第六步,是对加权value向量求和。这会在这个位置产生自注意力层的输出(对于第一个词)。

img

以上就是self-attention的这个计算过程,在实际应用中是以矩阵的形式运算的,下面看看矩阵运算。

自注意力的矩阵运算

第一步,将所有的输入词向量合并成输入矩阵 X X X,并将其分别乘以权重矩阵 W q , W k , W v W^q,W^k,W^v Wq,Wk,Wv,得到query/key/value矩阵。

img

最后,将2-6步合并成一个计算得到self-attention的输出。

img

2.6、多头注意力

transformer论文中在自注意力的基础上增加了多头的概念,它通过两种方式提高了注意力层的性能。

  1. 它扩展了模型关注不同位置的能力。在上面的例子中, Z 1 Z_1 Z1包含了一点其他位置的编码,它有可能被自己单词本身所主导。

  2. 它为自注意力提供了多个表示空间,使用多头注意力,我们有多组query/key/value权重矩阵(transformer中有八个注意力头,因此有八组query/key/value权重矩阵)。每一组的权重都是不同的,可以将输入投影到不同的表示子空间中。

那么它如何实现哪?

前面提到query、key、value向量的维度要比输入词向量的维度要小,这就是为了使用多头注意力,但是并不是随意的变小,而是有一定的规则。例如tranformer中输入向量的维度为512,头数为8,那么将输入向量与每个头的3个权重矩阵相乘(就是经过一个投影哇,没有偏置的线性层)得到的query,key,value的维度应为512/8=64,然后将这8个头的输出拼接起来,每个头是64,那么8个头的输出拼接还是512,最后再经过一个没有偏置的线性层 W o W^o Wo就得到了最终的输出。公式为:
M u l t i H e a d ( Q , K , V ) = C o n c a t ( h e a d 1 , ⋯   , h e a d h ) W o w h e r e    h e a d i = A t t e n t i o n ( Q W i Q , K W i K , V W i V ) MultiHead(Q,K,V)=Concat(head_1,\cdots ,head_h)W^o\\ where\;head_i=Attention(QW_i^Q,KW_i^K,VW_i^V) MultiHead(Q,K,V)=Concat(head1,,headh)Wowhereheadi=Attention(QWiQ,KWiK,VWiV)

下图为论文中的多头注意力的图。

img

注意,这里头的个数有要求,输入词向量的维度要与头的个数要能整除。

通过图举个例子,512维度太多了,这里以输入维度为4,头数为2为例进行演示,输入维度为4,头数为2,那么得到的query,key,value的维度应为4/2=2。然后每个头分别进行自注意力计算,最后合并输入一个线性层得到当前词最终的自注意力。

img

但是在代码实现中并不是这样计算的,以生成query的投影为例,8个头使用8个不同的线性投影得到8个不同的query,每个线性投影函数的输入为512,输出为64。而在计算中为了方便,将这8个线性投影合在一起成一个线形投影(增大参数量),线性投影函数的输入为512,输出还是512,然后将它们在与key和value(key和value的投影是一样的)一起计算各自头的自注意力,最后再将八个头的结果合并在一起然后经过一个线性层处理。这样就把3*8+3=27个线性投影降维了3+1=4个线性投影。下面为实现的代码:

#为了多注意力头的并行计算而变换形状
def transpose_qky(X,num_heads):
    # 输入X的形状:(batch_size,查询或者“键-值”对的个数,num_hiddens)
    # 输出X的形状:(batch_size,查询或者“键-值”对的个数,num_heads,
    # num_hiddens/num_heads)
    X = X.reshape(X.shape[0],X.shape[1],num_heads,-1)

    # 输出X的形状:(batch_size,num_heads,查询或者“键-值”对的个数,
    # num_hiddens/num_heads)
    X = X.permute(0,2,1,3)
    # 最终输出的形状:(batch_size*num_heads,查询或者“键-值”对的个数,
    # num_hiddens/num_heads)
    return X.reshape(-1,X.shape[2],X.shape[3])

#逆转transpose_qkv函数的操作
def transpose_output(X,num_heads):
    X = X.reshape(-1,num_heads,X.shape[1],X.shape[2])
    X = X.permute(0,2,1,3)
    return X.reshape(X.shape[0],X.shape[1],-1)

class MultiHeadAttention(nn.Module):
    def __init__(self,d_model,num_heads,dropout,bias = False,**kwargs):
        super(MultiHeadAttention, self).__init__(**kwargs)
        self.num_heads = num_heads
        self.attention = DotProductAttention(dropout)
        #对于多个头,也只使用一个q,k,v都只用一个Linear
        self.W_q = nn.Linear(d_model, d_model,bias = bias)
        self.W_k = nn.Linear(d_model, d_model, bias=bias)
        self.W_v = nn.Linear(d_model, d_model, bias=bias)
        self.W_o = nn.Linear(d_model,d_model,bias = bias)
    def forward(self,queries,keys,values,valid_lens):
        # queries,keys,values的形状:(batch_size,查询或者“键-值”对的个数,num_hiddens)
        # valid_lens 的形状: (batch_size,)或(batch_size,查询的个数)
        # 经过变换后,输出的queries,keys,values 的形状: (batch_size*num_heads,查询或者“键-值”对的个数, num_hiddens/num_heads)
        queries = transpose_qky(self.W_q(queries),self.num_heads)
        keys = transpose_qky(self.W_k(keys),self.num_heads)
        values = transpose_qky(self.W_v(values),self.num_heads)

        if valid_lens is not None:
            #上面通过三个linear计算所有个头的q,k,v,然后对结果进行矩阵变换然后再进行注意力计算
            #需要对每个头单独计算softmax,因此将每个头分开添加到batch那个维度上,因此需要将真实序列的长度进复制
            #在轴0,将第一项赋值`num_heads`次,然后复制第二项,依次复制
            valid_lens = torch.repeat_interleave(valid_lens,repeats = self.num_heads,dim = 0)
        #计算自注意力的结果
        # output的形状:(batch_size*num_heads,查询的个数,num_hiddens/num_heads)
        output = self.attention(queries,keys,values,valid_lens)

        #计算完注意力后,将形状改回来
        # output_concat的形状:(batch_size,查询的个数,num_hiddens)
        output_concat = transpose_output(output,self.num_heads)
        #然后与经过一个线形映射,得到最终结果
        return self.W_o(output_concat)

下面使用键和值相同的例子测试一下MultiHeadAttention类。多头注意力输出的形状应该为(批量大小,查询的个数(在真实情况中查询的个数与键值对个数相同),词向量长度),下面实例化一个q,k,v和词向量长度为100,头数为5的多头注意力。

d_model,num_heads = 100,5
#实例化模型
attention = MultiHeadAttention(d_model,num_heads,0.5)
attention.eval()
MultiHeadAttention(
  (attention): DotProductAttention(
    (dropout): Dropout(p=0.5, inplace=False)
  )
  (W_q): Linear(in_features=100, out_features=100, bias=False)
  (W_k): Linear(in_features=100, out_features=100, bias=False)
  (W_v): Linear(in_features=100, out_features=100, bias=False)
  (W_o): Linear(in_features=100, out_features=100, bias=False)
)

使用batch_size为2,查询的数目为4,键值对的个数为6,第一个批次真实长度为3,第二个批次真实长度为2,输入的向量特征值全为1。

batch_size,num_queries,num_kvpairs,valid_lens = 2,4,6,torch.tensor([3,2])
X = torch.ones((batch_size,num_queries,d_model))
Y = torch.ones((batch_size,num_kvpairs,d_model))
attention(X,Y,Y,valid_lens).shape
torch.Size([2, 4, 100])

3、词嵌入(Embeding) & 位置编码(Positional Encoding)

在介绍完自注意力以及多头自注意力后,下面看看处理模型输入信息以获得transformer中的多头自注意力的输入。主要经过两个步骤:词嵌入(Embedding)和位置嵌入(Positional Encoding),如下图所示。

img

3.1、词嵌入(Embedding)

与其他的序列转换模型类似,使用学习到的词嵌入将输入转为维度为 d m o d e l d_{model} dmodel的向量。与其他不同的是,需要把嵌入层中的每一个权重乘以 d m o d e l \sqrt{d_{model}} dmodel ,这样做是因为维度较长的时候,权重会变得比较小,因此乘以 d m o d e l \sqrt{d_{model}} dmodel 使得在它跟后面的位置编码有有差不多的尺度,这样就方便和位置编码进行相加。

代码实现如下:

class Embeddings(nn.Module):
    def __init__(self,vocab,d_model):
        super(Embeddings, self).__init__()
        self.lut = nn.Embedding(vocab,d_model)
        self.d_model = d_model
    def forward(self,X):
        return self.lut(X) * math.sqrt(self.d_model)

3.2、Positional Encoding

由于注意力是没有时序信息的,需要通过在输入表示中添加位置编码(positional encoding)来注入绝对或相对的位置信息。位置编码可以通过学习得到也可以直接固定得到。transformer使用的是基于正弦函数和余弦函数的固定位置编码。

假设输入表示 X ∈ R n × d X\in R^{n\times d} XRn×d包含一个序列中n个词元的d维嵌入表示。位置编码使用相同形状的位置嵌入矩阵 P ∈ R n × d P\in R^{n\times d} PRn×d输出 X + P X+P X+P,就是把词嵌入和位置编码的信息相加得到自注意力的输入:

img

位置嵌入矩阵第i行、第2j列和第2j+1列上的元素为:
p i , 2 j = s i n ( i 100 0 2 j / d ) p i , 2 j + 1 = s i n ( i 100 0 2 j / d ) p_{i,2j}=sin(\frac{i}{1000^{2j/d}})\\ p_{i,2j+1}=sin(\frac{i}{1000^{2j/d}}) pi,2j=sin(10002j/di)pi,2j+1=sin(10002j/di)

下面通过代码实现它:

#位置嵌入
class PositionalEncoding(nn.Module):
    def __init__(self,d_model,dropout,max_len = 1000):
        super(PositionalEncoding, self).__init__()
        self.dropout = nn.Dropout(dropout)

        #创建一个足够长的`P`
        self.P = torch.zeros(1,max_len,d_model)
        #计算i/10000^{2j/f} i为第几个词,j词当前词的词向量第一维
        X = torch.arange(max_len,dtype = torch.float32).reshape(-1,1) / torch.pow(10000,torch.arange(
            0,d_model,2,dtype = torch.float32) / d_model)
        #词向量为偶数项sin(X)
        self.P[:,:,0::2] = torch.sin(X)
        #词向量为奇数项sin(X)
        self.P[:,:,1::2] = torch.cos(X)

    def forward(self,X):
        #将位置编码添加到词嵌入X上
        X = X + self.P[:,:X.shape[1],:].to(X.device)
        return self.dropout(X)

在位置嵌入矩阵P中,行代表词元所在序列中的位置,列代表位置编码的不同维度。在下面的例子中,结果展示的是每个词的词向量第6,7,8,9维随不同位置词的变化图。可以看出第6维和第7维的频率要高于第8维和第9维,第6列和第7列之间的偏移量(第8列和第8列相同)是由于正弦函数和余弦函数的交替造成的。由于这种不同的位置添加到词向量上,就给词向量添加了位置信息。

#词嵌入长度,序列长度
encoding_dim,num_steps = 32,60
#实例化PositionalEncoding
pos_encoding = PositionalEncoding(encoding_dim,0)
pos_encoding.eval()
#将位置信息添加到批次为1,序列长度为60,词向量为32的序列上(该序列的词嵌入全为0)
X = pos_encoding(torch.zeros((1,num_steps,encoding_dim)))

P = pos_encoding.P[:, :X.shape[1], :]
#查看位置编码(由于是在全为0的词向量上添加的位置编码,因此能清晰看清位置编码的信息)
#查看60个词的词向量的第6,7,8,9维随词位置的变化而变化的情况
d2l.plot(torch.arange(num_steps),P[0,:,6:10].T,xlabel = 'Row(position)',figsize = (6,2.5),legend=["Col %d" % d for d in torch.arange(6,10)])


output_32_0

下面打印 0 , 1 , ⋯   , 7 0,1,\cdots,7 0,1,,7的二进制表示。可以看出每个数字、每两个数字、每四个数字上的比特值在第一个最低位、第二个最低位和第三个最低位上分别交替,以每两个数字为例:0的二进制为000,2的为010,4的为100,那么6的就是110,可见在第二个最低位上交替。

for i in range(8):
    print(f'{i}的二进制是:{i:>03b}')
0的二进制是:000
1的二进制是:001
2的二进制是:010
3的二进制是:011
4的二进制是:100
5的二进制是:101
6的二进制是:110
7的二进制是:111

较高的比特位交替频率低于较低比特位,这就与上面曲线图展示的类似,维度越高的频率越低。下面用热力图画出序列所有词的所有词向量的数值。每一个词向量与每一行的位置编码相加就得到了带有位置信息的self-attention的输入。

P = P[0, :, :].unsqueeze(0).unsqueeze(0)
d2l.show_heatmaps(P, xlabel='Column (encoding dimension)',
                  ylabel='Row (position)', figsize=(3.5, 4), cmap='Blues')


output_36_0

4、基于位置的前馈神经网络

在编码器中,主要由自注意力层和基于位置的前馈神经网络层组成,在前面介绍了自注意力层,下面介绍基于位置的前馈神经网络层。

基于位置的前馈神经网络对序列中的所有位置的表示进行变换时使用的是同一个多层感知机,这就是称为前馈神经网络的原因。就是对自注意力层的输出作为输入的一个多层感知机罢了。输入X的形状为(批量大小,时间步数或序列长度,词向量特征维度),然后经过一个两层的多层感知机转换成形状为(批量大小,时间步数或序列长度,词向量特征维度)。在transformer的实现中,多层感知机的输入的每个向量的特征维度为512,中间隐藏层的维度为2048,输出的特征维度为512,即d_ff=512。因此可以得到自注意力层和基于位置的前馈神经网络层所输入输出的每个词的特征维度都是512。基于位置的前馈神经网络的公式为:
F F N ( x ) = m a x ( 0 , x W 1 + b 1 ) W 2 + b 2 FFN(x)=max(0,xW_1+b_1)W_2+b_2 FFN(x)=max(0,xW1+b1)W2+b2

下面为基于位置的前馈神经网络的实现代码。

class PositionWiseFFN(nn.Module):
    def __init__(self,d_model,d_ff,**kwargs):
        super(PositionWiseFFN, self).__init__(**kwargs)
        self.dense1 = nn.Linear(d_model,d_ff)
        self.dens2 = nn.Linear(d_ff,d_model)
        
    def forward(self,X):
        return self.dens2(F.relu(self.dense1(X)))
ffn = PositionWiseFFN(4,8)
ffn.eval()
ffn(torch.ones((2, 3, 4)))[0]
tensor([[ 0.0142, -0.1695, -0.3843, -0.3802],
        [ 0.0142, -0.1695, -0.3843, -0.3802],
        [ 0.0142, -0.1695, -0.3843, -0.3802]], grad_fn=<SelectBackward0>)

5、残差连接和层归一化

在编码器和解码器的结构中,每个子层中,都有残差连接,然后使一个层归一化(layer-normalization)的操作。公式为:
L a y e r N o r m ( x + S u b l a y e r ( x ) ) LayerNorm(x+Sublayer(x)) LayerNorm(x+Sublayer(x))

下图为编码器中每个子层中残差连接和层归一化的操作。

img

残差连接很容易理解,就是把输入和输出的内容加起来,这使得网络可以搭建的比较深,这在很多的网络中都使用到了,可以提升模型的效果。

层归一化和批量归一化的目标相同,但是层归一化是基于特征维度的归一化。尽管批量归一化在计算机视觉中被广泛应用,但是在自然语言处理中(输入序列通常数变序)层归一化的效果比较好,关于批量归一化和层归一化更详细的内容可以看这个文章

通过AddNorm类来实现残差连接和层归一化的功能:

class AddNorm(nn.Module):
    def __init__(self,normalized_shape,dropout,**kwargs):
        super(AddNorm, self).__init__(**kwargs)
        self.dropout = nn.Dropout(dropout)
        #层归一化
        self.ln = nn.LayerNorm(normalized_shape)
    
    def forward(self,X,Y):
        return self.ln(self.dropout(Y) + X)

残差连接要求两个输入的形状相同,以便加法操作后输出张量的形状相同

add_norm = AddNorm([3,4],0.5)
add_norm.eval()
add_norm(torch.ones((2,3,4)),torch.ones((2,3,4))).shape
torch.Size([2, 3, 4])

6、编码器

上面介绍了自注意力机制、位置编码、基于位置的前馈神经网络、残差连接和层归一化,这些都是transformer编码器和解码器的重要组成部分,下面使用这些组件构建出编码器和解码器。先看看编码器是怎么组成的,下图红色框为编码器的结构。

img

从图中可以看出,transformer编码器是由N个编码器层堆叠而成。每一个编码器组件包含两个子层:多头注意力层和基于位置的前馈神经网络层。这两个子层都使用了残差连接和层归一化。然后N个这样的层堆叠起来组成最终的编码器。

下面通过代码实现N个编码器层中的一层。

class EncoderBlock(nn.Module):
    def __init__(self,d_model,norm_shape,d_ff,num_heads,dropout,use_bias = False,**kwargs):
        super(EncoderBlock, self).__init__(**kwargs)
        #多头注意力
        self.attention = MultiHeadAttention(d_model,num_heads,dropout,use_bias)
        # 多头注意力的残差和layernorm
        self.addnorm1 = AddNorm(norm_shape,dropout)
        #前馈神经网络
        self.ffn = PositionWiseFFN(d_model,d_ff)
        # 前馈神经网络的残差和layernorm
        self.addnorm2 = AddNorm(norm_shape,dropout)
    
    def forward(self,X,valid_lens):
        #计算多头注意力层
        Y = self.addnorm1(X,self.attention(X,X,X,valid_lens))
        #计算前馈神经网络层
        return self.addnorm2(Y,self.ffn(Y))
X = torch.ones((2, 100, 24))
valid_lens = torch.tensor([3, 2])
encoder_blk = EncoderBlock(24, [100, 24] ,48, 8, 0.5)
encoder_blk.eval()
encoder_blk(X, valid_lens).shape
torch.Size([2, 100, 24])

上面实现了编码器其中的一层,下面把多个层堆叠起来,并把处理输入信息的词嵌入和位置编码添加到第一层前面。

class TransformerEncoder(nn.Module):
    def __init__(self,vocab_size,d_model,
                 norm_shape,d_ff,num_heads,num_layers,dropout,use_bias = False,**kwargs):
        super(TransformerEncoder, self).__init__(**kwargs)
        self.d_model = d_model
        #词嵌入
        self.embedding = Embeddings(vocab_size,d_model)
        #位置编码
        self.pos_encoding = PositionalEncoding(d_model,dropout)
        #编码层
        self.blks = nn.Sequential()
        for i in range(num_layers):
            self.blks.add_module("block"+str(i),
                                 EncoderBlock(d_model,norm_shape,d_ff,num_heads,dropout,use_bias))

    def forward(self,X,valid_lens,*args):
        #词嵌入和位置编码
        X = self.pos_encoding(self.embedding(X))
        self.attention_weights = [None] * len(self.blks)
        #将编码层堆叠在一起
        for i,blk in enumerate(self.blks):
            X = blk(X,valid_lens)
            self.attention_weights[i] = blk.attention.attention.attention_weights
        return X

下面创建一个两层的Transformer编码器。Transformer编码器输出的形状是(批量大小,时间步长度,d_model)。

encoder = TransformerEncoder(200,24,[100,24],48,8,2,0.5)
encoder.eval()
valid_lens = torch.tensor([2, 6])
encoder(torch.ones((2,100),dtype = torch.long),valid_lens).shape
torch.Size([2, 100, 24])

7、解码器

Ttansformer的解码器也是由多个相同的层组成的。而解码器的每个层包括三个子层:带掩码的多头注意力、"编码器-解码器"多头注意力和基于位置的前馈网络。这些子层使用残差连接并使用层归一化处理。如下图红色框的解码器结构。

img

7.1、带掩码的自注意力

从图中可以看出,解码器的多头注意力与编码器是不同的,它多了一个掩码(Masked)。为什么要这么做呢?transformer是一个序列到序列模型,在训练阶段,解码器能看到所有位置的输出词元,但是在训练阶段,其输出序列的词元是逐个生成的。因此,为了统一训练和测试过程,只有生成的词元才能用于解码器的注意力计算中,其余部分都需要被遮盖掉以忽略掉它们。如下图所示,自注意力计算时,每一个输出需要考虑所有的输入,而掩码的自注意力只能考虑已经生成的词元。例如在生成 b 1 b^1 b1的时候只能考虑 a 1 a^1 a1,生成 b 2 b^2 b2的时候只能考虑 a 1 a^1 a1 a 2 a^2 a2,生成 b 3 b^3 b3的时候只能考虑 a 1 a^1 a1 a 2 a^2 a2 a 3 a^3 a3

img

其在代码实现过程中,只需要解码器当前输入的序列长度考虑成已经生成的词元的程度就好了。

7.2、“编码器-解码器”注意力

在编码器中,查询(query),键(key)和值(value)都来自同一个输入与三个不同的权重矩阵相乘得到。而在解码器的第二个子层的输入是不同的,其中查询(query)来自解码器上一个子层(带掩码的注意力)的输出,而键(key)和值(value)则来自编码器的输出,又称交叉注意力(Cross attention)。其过程如下图所示。

img

7.3 解码器整体结构 & 预测

在“编码器-解码器”注意层后还有一个基于位置的前馈神经网络。将这三个子层堆叠N层就得到了transformer的输出。

解码器的输入同编码器一样需要经过词嵌入和位置编码。在训练时,解码器的输入是全部的真实预测结果;在预测时,解码器的输入是上一次预测输出的结果。解码器的输入在经过解码器处理后,被传入一个线形层,再经过softmax处理得到最终的结果。

在带掩码的自注意力实现过程中,通过设定参数dec_valid_lens设定遮盖的范围,以便任何查询都只会与解码器中所有已经生成的词元的位置进行注意力计算。下面使用代码实现解码器一个层的结构。

class DecoderBlock(nn.Module):
    def __init__(self,d_model,norm_shape,d_ff,num_heads,dropout,i,**kwargs):
        super(DecoderBlock, self).__init__(**kwargs)
        #解码器第几层
        self.i = i
        #带掩码的多头注意力
        self.attention1 = MultiHeadAttention(d_model,num_heads,dropout)
        self.addnorm1 = AddNorm(norm_shape,dropout)
        #编码器-解码器交叉注意力
        self.attention2 = MultiHeadAttention(d_model,num_heads,dropout)
        self.addnorm2 = AddNorm(norm_shape,dropout)
        #基于位置的前馈神经网络
        self.ffn = PositionWiseFFN(d_model,d_ff)
        self.addnorm3 = AddNorm(norm_shape,dropout)

    def forward(self,X,state):
        #编码器输出,每个序列的真实长度
        enc_outputs,enc_valid_lens = state[0],state[1]

        #训练阶段,输出序列的所有词元都在同一时间处理,因此state[2][self.i]初始化为None
        #预测阶段,输出序列是通过词元一个接一个解码的,因此state[2][self.i]包含当前时间步第i个块解码的输出表示
        if state[2][self.i] is None:
            key_value = X
        else:
            #预测时,计算一个输出,然后将其加到编码器输入上面,这样计算自注意力时就能看到更多。
            key_value = torch.cat((state[2][self.i],X),axis = 1)
        state[2][self.i] = key_value
        if self.training:
            batch_size,num_steps,_ = X.shape
            #掩码多头自注意力的掩码,第一个词的能看的长度为1,第二个词能看的为2,依次类推
            dec_valid_lens = torch.arange(1,num_steps + 1,device = X.device).repeat(batch_size,1)
        else:
            dec_valid_lens = None

        #带掩码自注意力
        X2 = self.attention1(X,key_value,key_value,dec_valid_lens)
        Y = self.addnorm1(X,X2)
        #编码器解码器注意力
        Y2 = self.attention2(Y,enc_outputs,enc_outputs,enc_valid_lens)
        Z = self.addnorm2(Y,Y2)
        return self.addnorm3(Z,self.ffn(Z)),state

测试DecoderBlock类。

decoder_block = DecoderBlock(24,[100,24],48,8,0.5,0)
decoder_block.eval()
X = torch.ones((2,100,24))
state = [encoder_blk(X,valid_lens),valid_lens,[None]]
decoder_block(X,state)[0].shape
torch.Size([2, 100, 24])

将解码器层堆叠N次得到transformer的解码器,再解码器前加上词嵌入和位置编码,将解码器的输出传入线性层就得到了最终的结果,下面是整个解码器的代码。

class TransformerDecoder(nn.Module):
    def __init__(self,vocab_size,d_model,norm_shape,d_ff,num_heads,num_layers,dropout,**kwargs):
        super(TransformerDecoder, self).__init__(**kwargs)
        self.d_model = d_model
        self.num_layers = num_layers
        #词嵌入
        self.embedding = Embeddings(vocab_size,d_model)
        #位置编码
        self.pos_encoding = PositionalEncoding(d_model,dropout)
        #堆叠N个解码器层
        self.blks = nn.Sequential()
        for i in range(num_layers):
            self.blks.add_module("block" + str(i),
                                 DecoderBlock(d_model,norm_shape,d_ff,num_heads,dropout,i))

        #用于输出结果的线性层
        self.dense = nn.Linear(d_model,vocab_size)

    #返回解码器从编码器获得的输入
    def init_state(self,enc_outputs,enc_valid_lens,*args):
        #编码器输出,真实序列长度,以及每一层已经输出的词的词向量
        return [enc_outputs,enc_valid_lens,[None] * self.num_layers]
    def forward(self,X,state):
        #计算词嵌入和位置编码
        X = self.pos_encoding(self.embedding(X))
        #初始化注意力权重
        self._attention_weights = [[None] * len(self.blks) for _ in range(2)]
        #循环计算每一层
        for i,blk in enumerate(self.blks):
            X,state = blk(X,state)
            #记录注意力
            self._attention_weights[0][i] = blk.attention1.attention.attention_weights
            self._attention_weights[1][i] = blk.attention2.attention.attention_weights
        #返回经过线性层的结果
        return self.dense(X),state
    @property
    def  attention_weights(self):
        return self._attention_weights

8、编码器解码器

在了解了编码器和解码器各自是怎么工作的之后,下面就来看看他们是怎么协同工作的。编码器从输入序列的处理开始,最后编码器的输出被转换成K和V,用于解码器的“编码器-解码器”注意力计算。编码器将整个序列的处理完传给解码器。

在训练阶段,解码器的输入是整个序列,通过掩码使得解码器只看部分序列;在推理阶段,解码器的输入是上一个解码器的输出,重复到一个结束符的出现表示解码器完成了翻译输出,如下图所示。

img

下面的代码将编码器和解码器组合起来,生成一个完整的模型。

#编码器-解码器
class EncoderDecoder(nn.Module):
    def __init__(self, encoder, decoder, **kwargs):
        super(EncoderDecoder, self).__init__(**kwargs)
        self.encoder = encoder
        self.decoder = decoder

    def forward(self, enc_X, dec_X, *args):
        enc_outputs = self.encoder(enc_X, *args)
        dec_state = self.decoder.init_state(enc_outputs, *args)
        return self.decoder(dec_X, dec_state)

9、训练&预测

9.1、预处理数据集

下面使用在介绍编码器-解码器中使用的"英-法"翻译数据集对模型进行训练,详细的处理过程可以看之前的介绍,下面只放上处理过程的代码如下:

#处理数据集部分

#从文件中读取英文-法文数据集
def read_data_nmt():
    with open('fra-eng/fra.txt', 'r',encoding='utf-8') as f:
        return f.read()

#预处理数据
def preprocess_nmt(text):
    def no_space(char, prev_char):
        return char in set(',.!?') and prev_char != ' '

    # 使用空格替换不间断空格
    # 使用小写字母替换大写字母
    text = text.replace('\u202f', ' ').replace('\xa0', ' ').lower()
    # 在单词和标点符号之间插入空格
    out = [' ' + char if i > 0 and no_space(char, text[i - 1]) else char for i, char in enumerate(text)]

    return ''.join(out)

#对数据集进行词元化
def tokenize_nmt(text,num_examples = None):
    source,target = [],[]
    #按行分割成一个一个列表
    for i,line in enumerate(text.split('\n')):
        #大于样本数量,停止添加
        if num_examples and i > num_examples:
            break
        #按照\t将英文和法语分割开
        parts = line.split('\t')
        if len(parts) == 2:
            source.append(parts[0].split(' '))
            target.append(parts[1].split(' '))
    return source,target

#统计词元的频率,返回每个词元及其出现的次数,以一个字典形式返回。
def count_corpus(tokens):
    #这里的tokens是一个1D列表或者是2D列表
    if len(tokens) == 0 or isinstance(tokens[0], list):
        #将词元列表展平为一个列表
        tokens = [token for line in tokens for token in line]
    #该方法用于统计某序列中每个元素出现的次数,以键值对的方式存在字典中。
    return collections.Counter(tokens)

#文本词表
class Vocab:
    def __init__(self,tokens = None, min_freq = 0, reserved_tokens = None):
        if tokens is None:
            tokens = []
        if reserved_tokens is None:
            reserved_tokens = []
        #按照单词出现频率排序
        counter = count_corpus(tokens)
        #counter.items():为一个字典
        #lambda x:x[1]:对第二个字段进行排序
        #reverse = True:降序
        self._token_freqs = sorted(counter.items(),key = lambda x:x[1],reverse = True)

        #未知单词的索引为0
        #idx_to_token用于保存所有未重复的词元
        self.idx_to_token = ['<unk>'] + reserved_tokens
        #token_to_idx:是一个字典,保存词元和其对应的索引
        self.token_to_idx = {token:idx for idx,token in enumerate(self.idx_to_token)}

        for token, freq in self._token_freqs:
            #min_freq为最小出现的次数,如果小于这个数,这个单词被抛弃
            if freq < min_freq:
                break
            #如果这个词元未出现在词表中,将其添加进词表
            if token not in self.token_to_idx:
                self.idx_to_token.append(token)
                #因为第一个位置被位置单词占据
                self.token_to_idx[token] = len(self.idx_to_token) - 1
    #返回词表的长度
    def __len__(self):
        return len(self.idx_to_token)

    #获取要查询词元的索引,支持list,tuple查询多个词元的索引
    def __getitem__(self, tokens):
        if not isinstance(tokens,(list,tuple)):
            #self.unk:如果查询不到返回0
            return self.token_to_idx.get(tokens,self.unk)
        return [self.__getitem__(token) for token in tokens]

    # 根据索引查询词元,支持list,tuple查询多个索引对应的词元
    def to_tokens(self,indices):
        if not  isinstance(indices,(list,tuple)):
            return self.idx_to_token[indices]
        return [self.idx_to_token[index] for index in indices]
    @property
    def unk(self):
        return 0
    @property
    def token_freqs(self):
        return self._token_freqs

#截断和填充文本
def truncate_pad(line,num_steps,padding_token):
    if len(line) > num_steps:
        return line[:num_steps]
    return line + [padding_token] * (num_steps - len(line))

#为每一个序列添加结束符,并返回索引序列和未经截断的序列长度
def build_array_nmt(lines, vocab, num_steps):
    # 将词元序列,转为词元对应的索引序列
    lines = [vocab[l] for l in lines]

    lines = [l + [vocab['<eos>']] for l in lines]
    array = torch.tensor([truncate_pad(l, num_steps, vocab['<pad>']) for l in lines])
    valid_len = (array != vocab['<pad>']).type(torch.int32).sum(1)
    # array为经过截断与填充的序列
    # valid_len为未经过截断与填充的序列的长度。
    return array, valid_len

#构造一个Pytorch数据迭代器
def load_array(data_arrays,batch_size,is_train=True):
    dataset=data.TensorDataset(*data_arrays)
    return data.DataLoader(dataset,batch_size,shuffle=is_train)

#返回翻译数据集的迭代器和词表
def load_data_nmt(batch_size,num_steps,num_examples = 600):
    #加载并处理数据
    text = preprocess_nmt(read_data_nmt())
    #词元化
    source,target = tokenize_nmt(text,num_examples)
    #构建源语言和目标语言的词表
    src_vocab = Vocab(source,min_freq=2,reserved_tokens=['<pad>','<bos>','<eos>'])
    tgt_vocab = Vocab(target,min_freq=2,reserved_tokens=['<pad>','<bos>','<eos>'])
    #生成词元对应索引构成的语言序列
    src_array,src_valid_len = build_array_nmt(source,src_vocab,num_steps)
    tgt_array,tgt_valid_len = build_array_nmt(target,tgt_vocab,num_steps)
    #生成数据集的迭代器
    data_arrays = (src_array,src_valid_len,tgt_array,tgt_valid_len)
    data_iter = load_array(data_arrays,batch_size)
    return data_iter,src_vocab,tgt_vocab

9.2、模型训练

在每个时间步,解码器预测了输出词元的分布。与语言模型类似,可以使用softmax来获得分布,并计算交叉熵损失函数来进行优化。而在前面处理数据集的时候,我们在序列的末尾添加了特定的填充词元,将不同长度的序列变为了相同的长度。但是,填充的词元的预测应该排除在损失函数的计算之内。下面通过自定义的交叉熵损失函数实现这一目的。

#带mask的交叉熵损失函数
class MaskedSoftmaxCELoss(nn.CrossEntropyLoss):
    # pred的形状:(batch_size,num_steps,vocab_size)
    # label的形状:(batch_size,num_steps)
    # valid_len的形状:(batch_size,)
    def forward(self,pred,label,valid_len):
        weights = torch.ones_like(label)
        weights = sequence_mask(weights,valid_len)
        self.reduction = 'none'
        #计算真实损失
        unweighted_loss = super(MaskedSoftmaxCELoss,self).forward(pred.permute(0,2,1),label)
        #使用mask进行处理
        weighted_loss = (unweighted_loss * weights).mean(dim = 1)
        return weighted_loss

下面就开始定义相关参数,加载数据集,对模型进行训练。定义了使用GPU的方法和对梯度进行截断的方法。

在循环训练过程中,序列开始词元(“<bos>”)和原始输出序列拼接在一起作为解码器的输入。

#使用GPU
def try_gpu(i=0):
    if torch.cuda.device_count()>=i+1:
        return torch.device(f'cuda:{i}')
    return torch.device('cpu')
#梯度截断
def grad_clipping(net,theta):
    if isinstance(net,nn.Module):
        params = [p for p in net.parameters() if p.requires_grad]
    else:
        params = net.params
    norm = torch.sqrt(sum(torch.sum((p.grad ** 2)) for p in params))
    if norm > theta:
        for param in params:
            param.grad[:] *= theta / norm

def train(net,data_iter,lr,num_epochs,tgt_vocab,device):
    #初始化模型参数
    def xavier_init_weights(m):
        if type(m) == nn.Linear:
            nn.init.xavier_uniform_(m.weight)
        if type(m) == nn.GRU:
            for param in m._flat_weights_names:
                if "weight" in param:
                    nn.init.xavier_uniform_(m._parameters[param])
    net.apply(xavier_init_weights)
    net.to(device)
    #优化器和损失函数
    optimizer = torch.optim.Adam(net.parameters(),lr = lr)
    loss = MaskedSoftmaxCELoss()
    #开启训练模式
    net.train()
    animator = d2l.Animator(xlabel = 'epoch',ylabel = 'loss',xlim=[10,num_epochs])
    for epoch in range(num_epochs):
        timer = d2l.Timer()
        #训练损失总和,词元数量
        metric = d2l.Accumulator(2)
        for batch in data_iter:
            X,X_valid_len,Y,Y_valid_len = [x.to(device) for x in batch]
            #将开始符号添加到目标语言序列上
            bos = torch.tensor([tgt_vocab['<bos>']] * Y.shape[0],device = device).reshape(-1,1)
            dec_input = torch.cat([bos,Y[:,:-1]],1)
            #计算真实值
            Y_hat,_ = net(X,dec_input,X_valid_len)
            #计算损失
            l = loss(Y_hat,Y,Y_valid_len)
            l.sum().backward()
            grad_clipping(net,1)
            num_tokens = Y_valid_len.sum()
            optimizer.step()
            with torch.no_grad():
                metric.add(l.sum(),num_tokens)
        if (epoch + 1) % 10 == 0:
            animator.add(epoch + 1,(metric[0]/metric[1],))
    print(f'loss {metric[0] / metric[1]:.3f},{metric[1]/timer.stop():.1f} tokens.sec on {str(device)}')

由于训练的数据集不大,因此d_model没有由512变为32,头数由8改为4,层数由8改为2。

d_model, d_ff,num_heads,num_layers, dropout, batch_size, num_steps = 32,64,4,2, 0.1, 64, 10
lr, num_epochs, device = 0.005, 200, try_gpu()
norm_shape = [32]

#加载数据集,并获得源语言和目标语言的词表
train_iter, src_vocab, tgt_vocab = load_data_nmt(batch_size, num_steps)
#实例化transformr模型
encoder = TransformerEncoder(len(src_vocab), d_model,norm_shape, d_ff, num_heads,num_layers, dropout)
decoder = TransformerDecoder(len(tgt_vocab), d_model,norm_shape, d_ff, num_heads,num_layers, dropout)
net = EncoderDecoder(encoder, decoder)
train(net, train_iter, lr, num_epochs, tgt_vocab, device)
loss 0.033,4415.6 tokens.sec on cuda:0

output_70_1

9.3、模型预测

下面定义预测翻译结果的预测函数。

解码器当前时间步的输入都来自与前一时间步的预测词元。而开始词元是,当输出的序列遇到序列结束词元时,预测就结束了。

def predict(net,src_sentence,src_vocab,tgt_vocab,num_steps,device,save_attention_weights = False):
    #设置为评估模式
    net.eval()
    #对句子进行预处理 然后在后面添加<eos>,预测到eos时结束,
    src_tokens = src_vocab[src_sentence.lower().split(' ')] + [src_vocab['<eos>']]
    #句子的长度
    enc_valid_len = torch.tensor([len(src_tokens)],device = device)
    #对句子进行截断和填充处理
    src_tokens = truncate_pad(src_tokens,num_steps,src_vocab['<pad>'])
    #添加批量轴
    #添加一个指定维度,因为训练的时候有三个维度(批量,时间步,词向量),因此推理时也需要添加批量那一个维度
    enc_X = torch.unsqueeze(torch.tensor(src_tokens,dtype = torch.long,device = device),dim = 0)
    
    #获得编码器输出
    enc_outputs = net.encoder(enc_X,enc_valid_len)
    
    #获得解码器初始状态
    dec_state = net.decoder.init_state(enc_outputs,enc_valid_len)
    
    #添加批量轴
    #解码器的输入也是三个维度,因此添加一个批量维度,加码器的第一个输入为序列开始词元<bos>
    dec_X = torch.unsqueeze(torch.tensor([tgt_vocab['<bos>']],dtype = torch.long,device = device),dim = 0)
    
    #保存输出内容
    output_seq,attention_weight_seq = [],[]

    for _ in range(num_steps):
        #获得解码器输出
        Y,dec_state = net.decoder(dec_X,dec_state)
        #使用具有预测可能性最高的词元,作为解码器在下一时间步的输入
        dec_X = Y.argmax(dim = 2)
        #去除第0维所在的维度,并转为python数据类型
        pred = dec_X.squeeze(dim = 0).type(torch.int32).item()
        # 保存注意力权重(稍后讨论)
        if save_attention_weights:
            attention_weight_seq.append(net.decoder.attention_weights)
        if pred == tgt_vocab['<eos>']:
            break
        output_seq.append(pred)
    #通过构建的词表,将索引转为单词
    return ' '.join(tgt_vocab.to_tokens(output_seq)),attention_weight_seq

通过代码实现BLEU然后对预测的结果进行评估。

def bleu(preq_seq,label_seq,k):
    #对预测序列和标签序列进行分词
    pred_tokens,label_tokens = preq_seq.split(' '),label_seq.split(' ')
    #计算预测序列和标签序列的长度
    len_pred,len_label = len(pred_tokens),len(label_tokens)
    #惩罚果断的预测
    score = math.exp(min(0,1 - len_label / len_pred))
    for n in range(1,k + 1):
        #定义一个统计值和字典
        num_matches,label_subs = 0,collections.defaultdict(int)
        #统计n元语法所有对应的词及其数量
        for i in range(len_label - n + 1):
            label_subs[''.join(label_tokens[i : i + n])] +=1
        #在预测序列中找是否与标签序列中对应的n元语法
        for i in range(len_pred - n + 1):
            if label_subs[''.join(pred_tokens[i : i + n])] > 0:
                num_matches +=1
                label_subs[''.join(pred_tokens[i : i + n])] -= 1
        score *= math.pow(num_matches / (len_pred - n + 1),math.pow(0.5,n))
    return score

engs = ['go .', "i lost .", 'he\'s calm .', 'i\'m home .']
fras = ['va !', 'j\'ai perdu .', 'il est calme .', 'je suis chez moi .']
for eng, fra in zip(engs, fras):
    translation, dec_attention_weight_seq = predict(
        net, eng, src_vocab, tgt_vocab, num_steps, device, True)
    print(f'{eng} => {translation}, ',
          f'bleu {bleu(translation, fra, k=2):.3f}')
go . => va !,  bleu 1.000
i lost . => j'ai perdu .,  bleu 1.000
he's calm . => il est riche .,  bleu 0.658
i'm home . => je suis chez moi .,  bleu 1.000

10、代码整合

下面对数据处理,tranformer模型搭建,模型训练与预测的相关代码进行整合。

import torch
from torch import nn
import math
from d2l.torch import d2l
import torch.nn.functional as F
import os
import collections
from torch.utils import data
import math
%matplotlib inline

######1.数据处理部分######

#从文件中读取英文-法文数据集
def read_data_nmt():
    with open('fra-eng/fra.txt', 'r',encoding='utf-8') as f:
        return f.read()

#预处理数据
def preprocess_nmt(text):
    def no_space(char, prev_char):
        return char in set(',.!?') and prev_char != ' '

    # 使用空格替换不间断空格
    # 使用小写字母替换大写字母
    text = text.replace('\u202f', ' ').replace('\xa0', ' ').lower()
    # 在单词和标点符号之间插入空格
    out = [' ' + char if i > 0 and no_space(char, text[i - 1]) else char for i, char in enumerate(text)]

    return ''.join(out)

#对数据集进行词元化
def tokenize_nmt(text,num_examples = None):
    source,target = [],[]
    #按行分割成一个一个列表
    for i,line in enumerate(text.split('\n')):
        #大于样本数量,停止添加
        if num_examples and i > num_examples:
            break
        #按照\t将英文和法语分割开
        parts = line.split('\t')
        if len(parts) == 2:
            source.append(parts[0].split(' '))
            target.append(parts[1].split(' '))
    return source,target

#统计词元的频率,返回每个词元及其出现的次数,以一个字典形式返回。
def count_corpus(tokens):
    #这里的tokens是一个1D列表或者是2D列表
    if len(tokens) == 0 or isinstance(tokens[0], list):
        #将词元列表展平为一个列表
        tokens = [token for line in tokens for token in line]
    #该方法用于统计某序列中每个元素出现的次数,以键值对的方式存在字典中。
    return collections.Counter(tokens)

#文本词表
class Vocab:
    def __init__(self,tokens = None, min_freq = 0, reserved_tokens = None):
        if tokens is None:
            tokens = []
        if reserved_tokens is None:
            reserved_tokens = []
        #按照单词出现频率排序
        counter = count_corpus(tokens)
        #counter.items():为一个字典
        #lambda x:x[1]:对第二个字段进行排序
        #reverse = True:降序
        self._token_freqs = sorted(counter.items(),key = lambda x:x[1],reverse = True)

        #未知单词的索引为0
        #idx_to_token用于保存所有未重复的词元
        self.idx_to_token = ['<unk>'] + reserved_tokens
        #token_to_idx:是一个字典,保存词元和其对应的索引
        self.token_to_idx = {token:idx for idx,token in enumerate(self.idx_to_token)}

        for token, freq in self._token_freqs:
            #min_freq为最小出现的次数,如果小于这个数,这个单词被抛弃
            if freq < min_freq:
                break
            #如果这个词元未出现在词表中,将其添加进词表
            if token not in self.token_to_idx:
                self.idx_to_token.append(token)
                #因为第一个位置被位置单词占据
                self.token_to_idx[token] = len(self.idx_to_token) - 1
    #返回词表的长度
    def __len__(self):
        return len(self.idx_to_token)

    #获取要查询词元的索引,支持list,tuple查询多个词元的索引
    def __getitem__(self, tokens):
        if not isinstance(tokens,(list,tuple)):
            #self.unk:如果查询不到返回0
            return self.token_to_idx.get(tokens,self.unk)
        return [self.__getitem__(token) for token in tokens]

    # 根据索引查询词元,支持list,tuple查询多个索引对应的词元
    def to_tokens(self,indices):
        if not  isinstance(indices,(list,tuple)):
            return self.idx_to_token[indices]
        return [self.idx_to_token[index] for index in indices]
    @property
    def unk(self):
        return 0
    @property
    def token_freqs(self):
        return self._token_freqs

#截断和填充文本
def truncate_pad(line,num_steps,padding_token):
    if len(line) > num_steps:
        return line[:num_steps]
    return line + [padding_token] * (num_steps - len(line))

#为每一个序列添加结束符,并返回索引序列和未经截断的序列长度
def build_array_nmt(lines, vocab, num_steps):
    # 将词元序列,转为词元对应的索引序列
    lines = [vocab[l] for l in lines]

    lines = [l + [vocab['<eos>']] for l in lines]
    array = torch.tensor([truncate_pad(l, num_steps, vocab['<pad>']) for l in lines])
    valid_len = (array != vocab['<pad>']).type(torch.int32).sum(1)
    # array为经过截断与填充的序列
    # valid_len为未经过截断与填充的序列的长度。
    return array, valid_len

#构造一个Pytorch数据迭代器
def load_array(data_arrays,batch_size,is_train=True):
    dataset=data.TensorDataset(*data_arrays)
    return data.DataLoader(dataset,batch_size,shuffle=is_train)

#返回翻译数据集的迭代器和词表
def load_data_nmt(batch_size,num_steps,num_examples = 600):
    #加载并处理数据
    text = preprocess_nmt(read_data_nmt())
    #词元化
    source,target = tokenize_nmt(text,num_examples)
    #构建源语言和目标语言的词表
    src_vocab = Vocab(source,min_freq=2,reserved_tokens=['<pad>','<bos>','<eos>'])
    tgt_vocab = Vocab(target,min_freq=2,reserved_tokens=['<pad>','<bos>','<eos>'])
    #生成词元对应索引构成的语言序列
    src_array,src_valid_len = build_array_nmt(source,src_vocab,num_steps)
    tgt_array,tgt_valid_len = build_array_nmt(target,tgt_vocab,num_steps)
    #生成数据集的迭代器
    data_arrays = (src_array,src_valid_len,tgt_array,tgt_valid_len)
    data_iter = load_array(data_arrays,batch_size)
    return data_iter,src_vocab,tgt_vocab

######2.transforemr模型搭建######

#带遮盖的softmax

#在序列中屏蔽不相关的项
#X中超过valid_len的部分都会被遮住
def sequence_mask(X,valid_len,value = 0):
  maxlen = X.size(1)
  mask = torch.arange((maxlen),dtype = torch.float32,device = X.device)[None,:] < valid_len[:,None]
  X[~mask] = value
  return X

#通过在最后一个轴上掩蔽元素来执行softmax操作
def masked_softmax(X,valid_lens):
   # X:3D张量,valid_lens:1D或2D张量
  if valid_lens is None:
    return nn.functional.softmax(X,dim = -1)
  else:
    shape = X.shape
    if valid_lens.dim() == 1:
      valid_lens = torch.repeat_interleave(valid_lens,shape[1])
    else:
      valid_lens = valid_lens.reshape(-1)
    X = sequence_mask(X.reshape(-1,shape[-1]),valid_lens,value = -1e6)
    return nn.functional.softmax(X.reshape(shape),dim = -1)

#缩放点积注意力计算
class DotProductAttention(nn.Module):
    def __init__(self,dropout,**kwargs):
        super(DotProductAttention, self).__init__(**kwargs)
        self.dropout = nn.Dropout(dropout)

    # queries的形状:(batch_size,查询的个数,d)
    # keys的形状:(batch_size,“键-值”对的个数,d)
    # values的形状:(batch_size,“键-值”对的个数,值的维度)
    # valid_lens的形状:(batch_size,)或者(batch_size,查询的个数)
    def forward(self,queries,keys,values,valid_lens = None):
        d = queries.shape[-1]
        scores = torch.bmm(queries,keys.transpose(1,2)) / math.sqrt(d)
        self.attention_weights = masked_softmax(scores,valid_lens)
        return torch.bmm(self.dropout(self.attention_weights),values)


#多头注意力计算
#为了多注意力头的并行计算而变换形状
def transpose_qky(X,num_heads):
    # 输入X的形状:(batch_size,查询或者“键-值”对的个数,num_hiddens)
    # 输出X的形状:(batch_size,查询或者“键-值”对的个数,num_heads,
    # num_hiddens/num_heads)
    X = X.reshape(X.shape[0],X.shape[1],num_heads,-1)

    # 输出X的形状:(batch_size,num_heads,查询或者“键-值”对的个数,
    # num_hiddens/num_heads)
    X = X.permute(0,2,1,3)
    # 最终输出的形状:(batch_size*num_heads,查询或者“键-值”对的个数,
    # num_hiddens/num_heads)
    return X.reshape(-1,X.shape[2],X.shape[3])

#逆转transpose_qkv函数的操作
def transpose_output(X,num_heads):
    X = X.reshape(-1,num_heads,X.shape[1],X.shape[2])
    X = X.permute(0,2,1,3)
    return X.reshape(X.shape[0],X.shape[1],-1)

class MultiHeadAttention(nn.Module):
    def __init__(self,d_model,num_heads,dropout,bias = False,**kwargs):
        super(MultiHeadAttention, self).__init__(**kwargs)
        self.num_heads = num_heads
        self.attention = DotProductAttention(dropout)
        #对于多个头,也只使用一个q,k,v都只用一个Linear
        self.W_q = nn.Linear(d_model, d_model,bias = bias)
        self.W_k = nn.Linear(d_model, d_model, bias=bias)
        self.W_v = nn.Linear(d_model, d_model, bias=bias)
        self.W_o = nn.Linear(d_model,d_model,bias = bias)
    def forward(self,queries,keys,values,valid_lens):
        # queries,keys,values的形状:(batch_size,查询或者“键-值”对的个数,num_hiddens)
        # valid_lens 的形状: (batch_size,)或(batch_size,查询的个数)
        # 经过变换后,输出的queries,keys,values 的形状: (batch_size*num_heads,查询或者“键-值”对的个数, num_hiddens/num_heads)
        queries = transpose_qky(self.W_q(queries),self.num_heads)
        keys = transpose_qky(self.W_k(keys),self.num_heads)
        values = transpose_qky(self.W_v(values),self.num_heads)

        if valid_lens is not None:
            #上面通过三个linear计算所有个头的q,k,v,然后对结果进行矩阵变换然后再进行注意力计算
            #需要对每个头单独计算softmax,因此将每个头分开添加到batch那个维度上,因此需要将真实序列的长度进复制
            #在轴0,将第一项赋值`num_heads`次,然后复制第二项,依次复制
            valid_lens = torch.repeat_interleave(valid_lens,repeats = self.num_heads,dim = 0)
        #计算自注意力的结果
        # output的形状:(batch_size*num_heads,查询的个数,num_hiddens/num_heads)
        output = self.attention(queries,keys,values,valid_lens)

        #计算完注意力后,将形状改回来
        # output_concat的形状:(batch_size,查询的个数,num_hiddens)
        output_concat = transpose_output(output,self.num_heads)
        #然后与经过一个线形映射,得到最终结果
        return self.W_o(output_concat)
#词嵌入
class Embeddings(nn.Module):
    def __init__(self,vocab,d_model):
        super(Embeddings, self).__init__()
        self.lut = nn.Embedding(vocab,d_model)
        self.d_model = d_model
    def forward(self,X):
        return self.lut(X) * math.sqrt(self.d_model)

#位置嵌入
class PositionalEncoding(nn.Module):
    def __init__(self,d_model,dropout,max_len = 1000):
        super(PositionalEncoding, self).__init__()
        self.dropout = nn.Dropout(dropout)

        #创建一个足够长的`P`
        self.P = torch.zeros(1,max_len,d_model)
        #计算i/10000^{2j/f} i为第几个词,j词当前词的词向量第一维
        X = torch.arange(max_len,dtype = torch.float32).reshape(-1,1) / torch.pow(10000,torch.arange(
            0,d_model,2,dtype = torch.float32) / d_model)
        #词向量为偶数项sin(X)
        self.P[:,:,0::2] = torch.sin(X)
        #词向量为奇数项sin(X)
        self.P[:,:,1::2] = torch.cos(X)

    def forward(self,X):
        #将位置编码添加到词嵌入X上
        X = X + self.P[:,:X.shape[1],:].to(X.device)
        return self.dropout(X)
#基于位置的前馈神经网络
class PositionWiseFFN(nn.Module):
    def __init__(self,d_model,d_ff,**kwargs):
        super(PositionWiseFFN, self).__init__(**kwargs)
        self.dense1 = nn.Linear(d_model,d_ff)
        self.dens2 = nn.Linear(d_ff,d_model)
        
    def forward(self,X):
        return self.dens2(F.relu(self.dense1(X)))
#残差连接和层归一化
class AddNorm(nn.Module):
    def __init__(self,normalized_shape,dropout,**kwargs):
        super(AddNorm, self).__init__(**kwargs)
        self.dropout = nn.Dropout(dropout)
        #层归一化
        self.ln = nn.LayerNorm(normalized_shape)
    
    def forward(self,X,Y):
        return self.ln(self.dropout(Y) + X)

#编码器部分
#单层编码器
class EncoderBlock(nn.Module):
    def __init__(self,d_model,norm_shape,d_ff,num_heads,dropout,use_bias = False,**kwargs):
        super(EncoderBlock, self).__init__(**kwargs)
        #多头注意力
        self.attention = MultiHeadAttention(d_model,num_heads,dropout,use_bias)
        # 多头注意力的残差和layernorm
        self.addnorm1 = AddNorm(norm_shape,dropout)
        #前馈神经网络
        self.ffn = PositionWiseFFN(d_model,d_ff)
        # 前馈神经网络的残差和layernorm
        self.addnorm2 = AddNorm(norm_shape,dropout)
    
    def forward(self,X,valid_lens):
        #计算多头注意力层
        Y = self.addnorm1(X,self.attention(X,X,X,valid_lens))
        #计算前馈神经网络层
        return self.addnorm2(Y,self.ffn(Y))
#transformer中的编码器
class TransformerEncoder(nn.Module):
    def __init__(self,vocab_size,d_model,
                 norm_shape,d_ff,num_heads,num_layers,dropout,use_bias = False,**kwargs):
        super(TransformerEncoder, self).__init__(**kwargs)
        self.d_model = d_model
        #词嵌入
        self.embedding = Embeddings(vocab_size,d_model)
        #位置编码
        self.pos_encoding = PositionalEncoding(d_model,dropout)
        #编码层
        self.blks = nn.Sequential()
        for i in range(num_layers):
            self.blks.add_module("block"+str(i),
                                 EncoderBlock(d_model,norm_shape,d_ff,num_heads,dropout,use_bias))

    def forward(self,X,valid_lens,*args):
        #词嵌入和位置编码
        X = self.pos_encoding(self.embedding(X))
        self.attention_weights = [None] * len(self.blks)
        #将编码层堆叠在一起
        for i,blk in enumerate(self.blks):
            X = blk(X,valid_lens)
            self.attention_weights[i] = blk.attention.attention.attention_weights
        return X

#解码器部分
#单层解码器
class DecoderBlock(nn.Module):
    def __init__(self,d_model,norm_shape,d_ff,num_heads,dropout,i,**kwargs):
        super(DecoderBlock, self).__init__(**kwargs)
        #解码器第几层
        self.i = i
        #带掩码的多头注意力
        self.attention1 = MultiHeadAttention(d_model,num_heads,dropout)
        self.addnorm1 = AddNorm(norm_shape,dropout)
        #编码器-解码器交叉注意力
        self.attention2 = MultiHeadAttention(d_model,num_heads,dropout)
        self.addnorm2 = AddNorm(norm_shape,dropout)
        #基于位置的前馈神经网络
        self.ffn = PositionWiseFFN(d_model,d_ff)
        self.addnorm3 = AddNorm(norm_shape,dropout)

    def forward(self,X,state):
        #编码器输出,每个序列的真实长度
        enc_outputs,enc_valid_lens = state[0],state[1]

        #训练阶段,输出序列的所有词元都在同一时间处理,因此state[2][self.i]初始化为None
        #预测阶段,输出序列是通过词元一个接一个解码的,因此state[2][self.i]包含当前时间步第i个块解码的输出表示
        if state[2][self.i] is None:
            key_value = X
        else:
            #预测时,计算一个输出,然后将其加到编码器输入上面,这样计算自注意力时就能看到更多。
            key_value = torch.cat((state[2][self.i],X),axis = 1)
        state[2][self.i] = key_value
        if self.training:
            batch_size,num_steps,_ = X.shape
            #掩码多头自注意力的掩码,第一个词的能看的长度为1,第二个词能看的为2,依次类推
            dec_valid_lens = torch.arange(1,num_steps + 1,device = X.device).repeat(batch_size,1)
        else:
            dec_valid_lens = None

        #带掩码自注意力
        X2 = self.attention1(X,key_value,key_value,dec_valid_lens)
        Y = self.addnorm1(X,X2)
        #编码器解码器注意力
        Y2 = self.attention2(Y,enc_outputs,enc_outputs,enc_valid_lens)
        Z = self.addnorm2(Y,Y2)
        return self.addnorm3(Z,self.ffn(Z)),state

#transformer解码器
class TransformerDecoder(nn.Module):
    def __init__(self,vocab_size,d_model,norm_shape,d_ff,num_heads,num_layers,dropout,**kwargs):
        super(TransformerDecoder, self).__init__(**kwargs)
        self.d_model = d_model
        self.num_layers = num_layers
        #词嵌入
        self.embedding = Embeddings(vocab_size,d_model)
        #位置编码
        self.pos_encoding = PositionalEncoding(d_model,dropout)
        #堆叠N个解码器层
        self.blks = nn.Sequential()
        for i in range(num_layers):
            self.blks.add_module("block" + str(i),
                                 DecoderBlock(d_model,norm_shape,d_ff,num_heads,dropout,i))

        #用于输出结果的线性层
        self.dense = nn.Linear(d_model,vocab_size)

    #返回解码器从编码器获得的输入
    def init_state(self,enc_outputs,enc_valid_lens,*args):
        #编码器输出,真实序列长度,以及每一层已经输出的词的词向量
        return [enc_outputs,enc_valid_lens,[None] * self.num_layers]
    def forward(self,X,state):
        #计算词嵌入和位置编码
        X = self.pos_encoding(self.embedding(X))
        #初始化注意力权重
        self._attention_weights = [[None] * len(self.blks) for _ in range(2)]
        #循环计算每一层
        for i,blk in enumerate(self.blks):
            X,state = blk(X,state)
            #记录注意力
            self._attention_weights[0][i] = blk.attention1.attention.attention_weights
            self._attention_weights[1][i] = blk.attention2.attention.attention_weights
        #返回经过线性层的结果
        return self.dense(X),state
    @property
    def  attention_weights(self):
        return self._attention_weights

#编码器-解码器
class EncoderDecoder(nn.Module):
    def __init__(self, encoder, decoder, **kwargs):
        super(EncoderDecoder, self).__init__(**kwargs)
        self.encoder = encoder
        self.decoder = decoder

    def forward(self, enc_X, dec_X, *args):
        enc_outputs = self.encoder(enc_X, *args)
        dec_state = self.decoder.init_state(enc_outputs, *args)
        return self.decoder(dec_X, dec_state)

######3.transforemr模型训练######

#带mask的交叉熵损失函数
class MaskedSoftmaxCELoss(nn.CrossEntropyLoss):
    # pred的形状:(batch_size,num_steps,vocab_size)
    # label的形状:(batch_size,num_steps)
    # valid_len的形状:(batch_size,)
    def forward(self,pred,label,valid_len):
        weights = torch.ones_like(label)
        weights = sequence_mask(weights,valid_len)
        self.reduction = 'none'
        #计算真实损失
        unweighted_loss = super(MaskedSoftmaxCELoss,self).forward(pred.permute(0,2,1),label)
        #使用mask进行处理
        weighted_loss = (unweighted_loss * weights).mean(dim = 1)
        return weighted_loss
#使用GPU
def try_gpu(i=0):
    if torch.cuda.device_count()>=i+1:
        return torch.device(f'cuda:{i}')
    return torch.device('cpu')
#梯度截断
def grad_clipping(net,theta):
    if isinstance(net,nn.Module):
        params = [p for p in net.parameters() if p.requires_grad]
    else:
        params = net.params
    norm = torch.sqrt(sum(torch.sum((p.grad ** 2)) for p in params))
    if norm > theta:
        for param in params:
            param.grad[:] *= theta / norm
#模型训练函数
def train(net,data_iter,lr,num_epochs,tgt_vocab,device):
    #初始化模型参数
    def xavier_init_weights(m):
        if type(m) == nn.Linear:
            nn.init.xavier_uniform_(m.weight)
        if type(m) == nn.GRU:
            for param in m._flat_weights_names:
                if "weight" in param:
                    nn.init.xavier_uniform_(m._parameters[param])
    net.apply(xavier_init_weights)
    net.to(device)
    #优化器和损失函数
    optimizer = torch.optim.Adam(net.parameters(),lr = lr)
    loss = MaskedSoftmaxCELoss()
    #开启训练模式
    net.train()
    animator = d2l.Animator(xlabel = 'epoch',ylabel = 'loss',xlim=[10,num_epochs])
    for epoch in range(num_epochs):
        timer = d2l.Timer()
        #训练损失总和,词元数量
        metric = d2l.Accumulator(2)
        for batch in data_iter:
            X,X_valid_len,Y,Y_valid_len = [x.to(device) for x in batch]
            #将开始符号添加到目标语言序列上
            bos = torch.tensor([tgt_vocab['<bos>']] * Y.shape[0],device = device).reshape(-1,1)
            dec_input = torch.cat([bos,Y[:,:-1]],1)
            #计算真实值
            Y_hat,_ = net(X,dec_input,X_valid_len)
            #计算损失
            l = loss(Y_hat,Y,Y_valid_len)
            l.sum().backward()
            grad_clipping(net,1)
            num_tokens = Y_valid_len.sum()
            optimizer.step()
            with torch.no_grad():
                metric.add(l.sum(),num_tokens)
        if (epoch + 1) % 10 == 0:
            animator.add(epoch + 1,(metric[0]/metric[1],))
    print(f'loss {metric[0] / metric[1]:.3f},{metric[1]/timer.stop():.1f} tokens.sec on {str(device)}')

######4.transforemr模型预测与评估######
def predict(net,src_sentence,src_vocab,tgt_vocab,num_steps,device,save_attention_weights = False):
    #设置为评估模式
    net.eval()
    #对句子进行预处理 然后在后面添加<eos>,预测到eos时结束,
    src_tokens = src_vocab[src_sentence.lower().split(' ')] + [src_vocab['<eos>']]
    #句子的长度
    enc_valid_len = torch.tensor([len(src_tokens)],device = device)
    #对句子进行截断和填充处理
    src_tokens = truncate_pad(src_tokens,num_steps,src_vocab['<pad>'])
    #添加批量轴
    #添加一个指定维度,因为训练的时候有三个维度(批量,时间步,词向量),因此推理时也需要添加批量那一个维度
    enc_X = torch.unsqueeze(torch.tensor(src_tokens,dtype = torch.long,device = device),dim = 0)
    
    #获得编码器输出
    enc_outputs = net.encoder(enc_X,enc_valid_len)
    
    #获得解码器初始状态
    dec_state = net.decoder.init_state(enc_outputs,enc_valid_len)
    
    #添加批量轴
    #解码器的输入也是三个维度,因此添加一个批量维度,加码器的第一个输入为序列开始词元<bos>
    dec_X = torch.unsqueeze(torch.tensor([tgt_vocab['<bos>']],dtype = torch.long,device = device),dim = 0)
    
    #保存输出内容
    output_seq,attention_weight_seq = [],[]

    for _ in range(num_steps):
        #获得解码器输出
        Y,dec_state = net.decoder(dec_X,dec_state)
        #使用具有预测可能性最高的词元,作为解码器在下一时间步的输入
        dec_X = Y.argmax(dim = 2)
        #去除第0维所在的维度,并转为python数据类型
        pred = dec_X.squeeze(dim = 0).type(torch.int32).item()
        # 保存注意力权重(稍后讨论)
        if save_attention_weights:
            attention_weight_seq.append(net.decoder.attention_weights)
        if pred == tgt_vocab['<eos>']:
            break
        output_seq.append(pred)
    #通过构建的词表,将索引转为单词
    return ' '.join(tgt_vocab.to_tokens(output_seq)),attention_weight_seq
#模型评估
def bleu(preq_seq,label_seq,k):
    #对预测序列和标签序列进行分词
    pred_tokens,label_tokens = preq_seq.split(' '),label_seq.split(' ')
    #计算预测序列和标签序列的长度
    len_pred,len_label = len(pred_tokens),len(label_tokens)
    #惩罚果断的预测
    score = math.exp(min(0,1 - len_label / len_pred))
    for n in range(1,k + 1):
        #定义一个统计值和字典
        num_matches,label_subs = 0,collections.defaultdict(int)
        #统计n元语法所有对应的词及其数量
        for i in range(len_label - n + 1):
            label_subs[''.join(label_tokens[i : i + n])] +=1
        #在预测序列中找是否与标签序列中对应的n元语法
        for i in range(len_pred - n + 1):
            if label_subs[''.join(pred_tokens[i : i + n])] > 0:
                num_matches +=1
                label_subs[''.join(pred_tokens[i : i + n])] -= 1
        score *= math.pow(num_matches / (len_pred - n + 1),math.pow(0.5,n))
    return score

d_model, d_ff,num_heads,num_layers, dropout, batch_size, num_steps = 32,64,4,2, 0.1, 64, 10
lr, num_epochs, device = 0.005, 200, try_gpu()
norm_shape = [32]

#加载数据集,并获得源语言和目标语言的词表
train_iter, src_vocab, tgt_vocab = load_data_nmt(batch_size, num_steps)
#实例化transformr模型
encoder1 = TransformerEncoder(len(src_vocab), d_model,norm_shape, d_ff, num_heads,num_layers, dropout)
decoder1 = TransformerDecoder(len(tgt_vocab), d_model,norm_shape, d_ff, num_heads,num_layers, dropout)
net1 = EncoderDecoder(encoder1, decoder1)
train(net1, train_iter, lr, num_epochs, tgt_vocab, device)
loss 0.031,5271.8 tokens.sec on cuda:0

output_78_1

engs = ['go .', "i lost .", 'he\'s calm .', 'i\'m home .']
fras = ['va !', 'j\'ai perdu .', 'il est calme .', 'je suis chez moi .']
for eng, fra in zip(engs, fras):
    translation, dec_attention_weight_seq = predict(
        net1, eng, src_vocab, tgt_vocab, num_steps, device,True)
    print(f'{eng} => {translation}, ',
          f'bleu {bleu(translation, fra, k=2):.3f}')
go . => va !,  bleu 1.000
i lost . => j'ai perdu .,  bleu 1.000
he's calm . => il est calme .,  bleu 1.000
i'm home . => je suis chez moi .,  bleu 1.000

参考资料

  1. Attention Is All You Need
  2. 动手学深度学习
  3. The Illustrated Transformer
  4. 李宏毅课程
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值