本文参考自https://github.com/datawhalechina/learn-nlp-with-transformers/blob/main/docs/
在学习了图解Transformer以后,需要用Pytorch编写Transformer,下面是写代码的过程中的总结,结构根据图解Transformer进行说明。
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import math, copy, time
from torch.autograd import Variable
import matplotlib.pyplot as plt
import seaborn
import matplotlib
seaborn.set_context(context="talk")
%matplotlib inline
Pytorch编写完整的Transformer
基础的EncoderDecoder结构
class EncoderDecoder(nn.Module):# 基础的Encoder-Decoder结构def __init__(self, encoder, decoder, src_embed, tgt_embed, generator):super().__init__()self.encoder = encoderself.decoder = decoderself.src_embed = src_embedself.tgt_embed = tgt_embedself.generator = generatordef forward(self, src, tgt, src_mask, tgt_mask):return self.decode(self.encode(src, src_mask), src_mask, tgt, tgt_mask)def encode(self, src, src_mask):return self.encoder(self.src_embed(src), src_mask)def decode(self, memory, src_mask, tgt, tgt_mask):return self.decoder(self.tgt_embed(tgt), memory, src_mask, tgt_mask)
基础的EncoderDecoder
结构包含encoder部分
,decoder
部分,src_embed
源语言嵌入层,tgt_embed
目标语言嵌入层,generator
生成器(包含linear
和softmax
),用来将decoder
的输出映射到词表维度,并用softmax
转换成概率,generator
的代码如下:
class Generator(nn.Module):# 定义生成器, 由linear和softmax组成def __init__(self, d_model, vocab):super().__init__()self.proj = nn.Linear(d_model, vocab)def forward(self, x):return F.log_softmax(self.proj(x), dim=-1)
Transformer
Transformer的编码器和解码器都使用self-attention和全连接层堆叠而成:
Encoder
完整的Encoder
部分由N=6
个完全相同的encoder_layer
组成。
clones
函数用来复制层,产生N
个相同的层:
def clones(module, N):# 产生N个完全相同的网络层return nn.ModuleList([copy.deepcopy(module) for _ in range(N)])
Encoder
部分就是经过N=6
个encoder_layer
,这里在最后额外添加了一个层标准化(layer-normalization),以保持架构的一致性:
class Encoder(nn.Module):# 完整的Encodersdef __init__(self, layer, N):super().__init__()self.layers = clones(layer, N)self.norm = LayerNorm(layer.size)def forward(self, x, mask):# 每一层的输入是x和maskfor layer in self.layers:x = layer(x, mask)return self.norm(x)
Encoder
的每层encoder_layer
包含self attention
子层和FFNN
子层,每个子层都使用了残差连接,和层标准化,下面是层标准化的代码:
class LayerNorm(nn.Module):"Construct a layernorm module (See citation for details)."def __init__(self, features, eps=1e-6):super().__init__()self.a_2 = nn.Parameter(torch.ones(features))self.b_2 = nn.Parameter(torch.zeros(features))self.eps = epsdef forward(self, x):mean = x.mean(-1, keepdim=True)std = x.std(-1, keepdim=True)return self.a_2 * (x - mean) / (std + self.eps) + self.b_2
这里的a_2
是可学习的参数,用于调整归一化输出的尺度,初始化为1,b_2
也是可学习的参数,在归一化输出中添加偏置,初始化为0,之所以加这个附加的缩放和平移变换改变取值空间,是为了使得归一化不对网络的表示能力造成负面影响,这里使用的是层归一化,对中间层的所有神经元进行归一化,这里给出公式,令第 l l l层神经元的净输入为 z ( l ) z^{(l)} z(l),其均值和方差分别为:
μ ( l ) = 1 M l ∑ i = 1 M l z i l , \mu^{(l)}=\frac{1}{M_l}\sum^{M_l}_{i=1} z^{l}_{i}, μ(l)=Ml1i=1∑Mlzil,
σ ( l ) 2 = 1 M l ( z i l − μ ( l ) ) 2 , {\sigma^{(l)}}^2=\frac{1}{M_l} (z^{l}_i-\mu^{(l)})^2, σ(l)2=Ml1(zil−μ(l))2,
其中 M l M_l Ml为第 l l l层神经元的数量。
层归一化定义为:
z ^ ( l ) = z ( l ) − μ ( l ) σ ( l ) 2 + ϵ ⊙ γ + β ≜ L N γ , β ( z ( l ) ) \begin{aligned} \hat{\boldsymbol{z}}^{(l)} & =\frac{\boldsymbol{z}^{(l)}-\mu^{(l)}}{\sqrt{\sigma^{(l)^2}+\epsilon}} \odot \gamma+\boldsymbol{\beta} \\ & \triangleq \mathrm{LN}_{\boldsymbol{\gamma}, \boldsymbol{\beta}}\left(\boldsymbol{z}^{(l)}\right) \end{aligned} z^(l)=σ(l)2+ϵz(l)−μ(l)⊙γ+β≜LNγ,β(z(l))
其中 γ , β \gamma,\beta γ,β分别代表缩放和平移的参数向量,和 z ( l ) z^{(l)} z(l)维数相同。
层归一化和批量归一化的区别是,BatchNorm
在每个批次中计算均值和方差,然后使用这些统计量对每个特征进行归一化,是对单个神经元进行操作,LayerNorm
是对每个样本的特征进行归一化,通常沿着最后一个维度(特征维度)进行,是对整层的神经元进行操作。
我们称呼子层为: S u b l a y e r ( x ) Sublayer(x) Sublayer(x),每个子层的最终输出是 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))。dropout被加载Sublayer上。为了便于残差连接,模型中的所有子层以及embedding层产生的输出的维度都为 d m o d e l = 512 d_{model}=512 dmodel=512。下面的SublayerConnection类用来处理单个Sublayer的输出,该输出将继续被输入下一个Sublayer。
class SublayerConnection(nn.Module):def __init__(self, size, dropout):super().__init__()self.norm = LayerNorm(size)self.dropout = nn.Dropout(dropout)def forward(self, x, sublayer):return x + self.dropout(sublayer(self.norm(x)))
每一层encoder
都有两个子层。第一层是一个multi-head self-attention
层,第二层是一个全连接前馈网络,对于这两层都需要使用SublayerConnection
类进行处理,见下图。
class EncoderLayer(nn.Module):def __init__(self, size, self_attn, feed_forward, dropout):super().__init__()self.self_attn = self_attnself.feed_forward = feed_forwardself.sublayer = clones(SublayerConnection(size, dropout), 2)self.size = sizedef forward(self, x, mask):x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, mask))return self.sublayer[1](x, self.feed_forward)
这里的EncoderLayer
就是编码器层,由两个子层构成(这里使用clone)复制了两个sublayer。
Decoder
解码器也是由 N = 6 N=6 N=6个完全相同的decoder层组成。这里也在最后额外添加了一个层标准化(layer-normalization),以保持架构的一致性:
class Decoder(nn.Module):def __init__(self, layer, N):super(Decoder, self).__init__()self.layers = clones(layer, N)self.norm = LayerNorm(layer.size)def forward(self, x, memory, src_mask, tgt_mask):for layer in self.layers:x = layer(x, memory, src_mask, tgt_mask)return self.norm(x)
单层decoder
和单层encoder
,decoder
还有第三个子层,该层对encoder
:即encoder-decoder-attention
层,q
向量来自decoder
上一层的输出,k
和v
向量是encoder
最后层的输出向量。与encoder
类似,我们在每个子层再采用残差连接,然后进行层标准化。
class DecoderLayer(nn.Module):def __init__(self, size, self_attn, src_attn, feed_forward, dropout):super(DecoderLayer, self).__init__()self.size = sizeself.self_attn = self_attnself.src_attn = src_attnself.feed_forward = feed_forwardself.sublayer = clones(SublayerConnection(size, dropout), 3)def forward(self, x, memory, src_mask, tgt_mask):m = memoryx = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, tgt_mask))x = self.sublayer[1](x, lambda x: self.src_attn(x, m, m, src_mask))return self.sublayer[2](x, self.feed_forward)
对于单层decoder
中的self-attention
子层,需要使用mask
机制,以防止在当前位置关注到后面的位置(这里创建了一个元素为1的上三角矩阵,然后用from_numpy将np数组转换为torch张量,并使用比较操作==0来反转掩码的逻辑,即取出掩码部分,这样上三角部分为0,表示掩码部分,主对角线和下三角部分为1,表示非掩码部分)。
def subsequent_mask(size):attn_shape = (1, size, size)subsequent_mask = np.triu(np.ones(attn_shape), k=1).astype('uint8')return torch.from_numpy(subsequent_mask) == 0
plt.figure(figsize=(5,5))
plt.imshow(subsequent_mask(20)[0])
Attention
Attention的功能可以描述为将query和一组key-value映射到输出,其中query、key、value和输出都是向量。输出为value的加权和,其中每个value的权重通过query与相应key的计算得到。
我们将particular attention称之为缩放的点积Attention(Scaled Dot-Product Attention
)。其输入为query、key(维度是 d k d_k dk)以及values(维度是d_v)。我们计算query和所有key的点积,然后对每个除以 d k \sqrt{d_k} dk,最后用softmax函数获得value的权重。
在实践中,我们同时计算一组query
的attention
函数,并将它们组合成一个矩阵Q。key
和value
也一起组成矩阵K和V。我们计算的输出矩阵为:
Attention ( Q , K , V ) = softmax ( Q K T d k ) V \operatorname{Attention}(Q, K, V)=\operatorname{softmax}\left(\frac{Q K^T}{\sqrt{d_k}}\right) V Attention(Q,K,V)=softmax(dkQKT)V
def attention(query, key, value, mask=None, dropout=None):d_k = query.size(-1)scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k)if mask is not None:scores = scores.masked_fill(mask == 0, -1e9)p_attn = F.softmax(scores, dim = -1)if dropout is not None:p_attn = dropout(p_attn)return torch.matmul(p_attn, value), p_attn
常用的注意力打分函数有:
- 加性模型 s ( x , q ) = v T t a n h ( W x + U q ) , s(\bm x,\bm q)=\bm v^Ttanh(\bm W\bm x+\bm U\bm q), s(x,q)=vTtanh(Wx+Uq),
- 点积模型 s ( x , q ) = x T q , s(\bm x,\bm q)=\bm x^T \bm q, s(x,q)=xTq,
- 缩放点积模型 s ( x , q ) = x T q D , s(\bm x, \bm q)=\frac{\bm x^T \bm q}{\sqrt{D}}, s(x,q)=DxTq,
- 双线性模型 s ( x . q ) = x T W q s(\bm x. \bm q)=\bm x^T \bm W \bm q s(x.q)=xTWq
理论上,加性模型和点积模型的复杂度差不多,但是点积模型在实现上可以更好地利用矩阵乘积,从而计算效率更高,当输入向量维度D比较高时,点积模型的值通常会有比较大的方差,从而导致Softmax函数的梯度会比较小( σ ( z i ) ( 1 − σ ( z i ) ) \sigma(z_i)(1-\sigma(z_i)) σ(zi)(1−σ(zi)),类别分布极度不均匀时,某些非常大概率的类别和其他小概率类别都会导致梯度消失问题),如果 q q q, k k k是独立的随机变量,均值是0,方差为1,那么它们的点积 q ⋅ k q \cdot k q⋅k均值为0方差为 d k d_k dk,具体推导如下:
E ( q i ⋅ k i ) = E ( q i ) ⋅ E ( k i ) + C o v ( q i , k i ) = E ( q i ) ⋅ E ( k i ) E(q_i \cdot k_i)=E(q_i)\cdot E(k_i)+Cov(q_i,k_i)=E(q_i)\cdot E(k_i) E(qi⋅ki)=E(qi)⋅E(ki)+Cov(qi,ki)=E(qi)⋅E(ki)
V a r ( q i ⋅ k i ) = V a r ( q i ) ⋅ V a r ( k i ) + V a r ( q i ) ⋅ E ( k i ) 2 + V a r ( k i ) ⋅ E ( q i ) = V a r ( q i ) ⋅ V a r ( k i ) Var(q_i \cdot k_i)=Var(q_i) \cdot Var(k_i)+ Var(q_i)\cdot E(k_i)^2+Var(k_i)\cdot E(q_i)=Var(q_i)\cdot Var(k_i) Var(qi⋅ki)=Var(qi)⋅Var(ki)+Var(qi)⋅E(ki)2+Var(ki)⋅E(qi)=Var(qi)⋅Var(ki)
E ( q ⋅ k ) = ∑ i = 1 d k E ( q i ⋅ k i ) = 0 E(q\cdot k)=\sum_{i=1}^{d_k}E(q_i\cdot k_i)=0 E(q⋅k)=i=1∑dkE(qi⋅ki)=0
V a r ( q ⋅ k ) = ∑ i = 1 d k V a r ( q i ⋅ k i ) + 2 ∑ i = 1 d k − 1 ∑ j = i + 1 d k C o v ( q i k i , q j k j ) = ∑ i = 1 d k V a r ( q i ⋅ k i ) = d k Var(q\cdot k)=\sum_{i=1}^{d_k}Var(q_i\cdot k_i)+2\sum_{i=1}^{d_k-1}\sum_{j=i+1}^{d_k}Cov(q_ik_i,q_jk_j)=\sum_{i=1}^{d_k}Var(q_i \cdot k_i)=d_k Var(q⋅k)=i=1∑dkVar(qi⋅ki)+2i=1∑dk−1j=i+1∑dkCov(qiki,qjkj)=i=1∑dkVar(qi⋅ki)=dk,所以为了抵消这种放大方差的影响,将点积缩小 1 d k \frac{1}{\sqrt{d_k}} dk1倍。
Multi-head attention
允许模型同时关注来自不同位置的不同表示子空间的信息,如果只有一个attention head
,向量的表示能力会下降。
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 , MultiHead(Q,K,V)=Concat(head_1,...,head_h)W^O, MultiHead(Q,K,V)=Concat(head1,...,headh)WO,
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 ) . where head_i = Attention(QW_i^Q,KW_i^K,VW_i^V). whereheadi=Attention(QWiQ,KWiK,VWiV).
映射由权重矩阵完成: W i Q ∈ R d m o d e l × d k W_i^Q \in R^{d_{model}\times d_k} WiQ∈Rdmodel×dk, W i K ∈ R d m o d e l × d k W_i^K \in R^{d_{model}\times d_k} WiK∈Rdmodel×dk, W i V ∈ R d m o d e l × d v W_i^V \in R^{d_{model}\times d_v} WiV∈Rdmodel×dv 和 W i O ∈ R h d v × d m o d e l W_i^O \in R^{hd_{v}\times d_{model}} WiO∈Rhdv×dmodel。
在这项工作中,我们采用 h = 8 h=8 h=8个平行attention
层或者叫head
。对于这些head
中的每一个,我们使用 d k = d v = d m o d e l / h = 64 d_k=d_v=d_{model}/h=64 dk=dv=dmodel/h=64。由于每个head的维度减小,总计算成本与具有全部维度的单个head attention
相似。
class MultiHeadedAttention(nn.Module):def __init__(self, h, d_model, dropout=0.1):super(MultiHeadedAttention, self).__init__()assert d_model % h == 0self.d_k = d_model // hself.h = hself.linears = clones(nn.Linear(d_model, d_model), 4)self.attn = Noneself.dropout = nn.Dropout(p=dropout)def forward(self, query, key, value, mask=None):if mask is not None:mask = mask.unsqueeze(1)nbatches = query.size(0)query, key, value = [l(x).view(nbatches, -1, self.h, self.d_k).transpose(1, 2)for l, x in zip(self.linears, (query, key, value))]x, self.attn = attention(query, key, value, mask=mask, dropout=self.dropout)x = x.transpose(1, 2).contiguous().view(nbatches, -1, self.h * self.d_k)return self.linears[-1](x)
上面clone
了四个linears
,分别用于 q , k , v q,k,v q,k,v变换和最后输出的变换,assert
了d_model
一定能整除h
,并且d_k = d_model // h
,这里的四个线性层都是输入d_model
输出d_model
,相当于变换并没有改变维度,变换后再切割成h
个头,相当于最开始先得到[...,d_model]
这样的Q,K,V
,然后通过view(nbatches, -1, self.h, self.d_k)
这样分出h
个头,做了attention
之后再合并成[nbatches, -1, self.h * self.d_k]
这样的维度。
模型中Attention的应用
multi-head attention在Transformer中有三种不同的使用方式:
- 在encoder-decoder attention层中,queries来自前面的decoder层,而keys和values来自encoder的输出。这使得decoder中的每个位置都能关注到输入序列中的所有序列。这是模仿序列到序列模型中典型的编码器——解码器的attention机制。
- encoder包含self-attention层。在self-attention层中,所有key,value和query来自同一个地方,即encoder中前一层的输出。在这种情况下,encoder中的每个位置都可以关注到encoder上一层的所有位置。
- 类似的,decoder中的self-attention层允许decoder中的每个位置都关注到decoder层中当前位置之前的所有位置(包括当前位置)。为了保持解码器的自回归特性,需要防止解码器中的信息向左流动。我们在缩放点积attention的内部,通过屏蔽softmax输入中所有的非法连接值(设置为 − ∞ -\infty −∞)实现这一点。
基于位置的前馈网络
除了attention子层之外,我们的编码器和解码器中的每个层都包含一个全连接的前馈网络,该网络在每个层的位置相同(都在每个encoder-layer或者decoder-layer的最后)。该前馈网络包括两个线性变换,并在两个线性变换中间有一个ReLU激活函数。
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
尽管两层都是线性变换,但它们在层与层之间使用不同的参数。另一种描述方式是两个内核大小为1的卷积。输入和输出维度都是 d m o d e l = 512 d_{model}=512 dmodel=512,内层维度是 d f f = 2048 d_{ff}=2048 dff=2048。(也就是第一层输入512维,输出2048维;第二层输入2048维,输出512维)
class PositionwiseFeedForward(nn.Module):def __init__(self, d_model, d_ff, dropout=0.1):super(PositionwiseFeedForward, self).__init__()self.w_1 = nn.Linear(d_model, d_ff)self.w_2 = nn.Linear(d_ff, d_model)self.dropout = nn.Dropout(dropout)def forward(self, x):return self.w_2(self.dropout(F.relu(self.w_1(x))))
Embeddings and Softmax
与其他seq2seq模型类似,我们使用学习到的embedding将输入token和输出token转换为 d m o d e l d_{model} dmodel维的向量。我们还使用普通的线性变换和softmax函数将解码器输出转换为预测的下一个token的概率,在我们的模型中,两个嵌入层之间和pre-softmax线性变换共享相同的权重矩阵。在embedding层中,我们将这些权重乘以 d m o d e l \sqrt{d_{model}} dmodel(一种规范化的手段,缩放嵌入向量,防止梯度消失)。
class Embeddings(nn.Module):def __init__(self, d_model, vocab):super().__init__()self.lut = nn.Embedding(vocab, d_model)self.d_model = d_modeldef forward(self, x):return self.lut(x) * math.sqrt(self.d_model)
位置编码
由于我们的模型不包含循环和卷积,为了让模型利用序列的顺序,我们必须加入一些序列中token的相对或者绝对位置的信息。为此,我们将”位置编码“添加到编码器和解码器堆栈底部的输入embedding中。位置编码和embedding的维度相同,也是 d m o d e l d_{model} dmodel,所以这两个向量可以相加。有多种位置编码可以选择,例如通过学习得到的位置编码和固定的位置编码。
在这项工作中,我们使用不同频率的正弦和余弦函数:
P E ( p o s , 2 i ) = sin ( p o s / 1000 0 2 i / d m o d e l ) P E ( p o s , 2 i + 1 ) = cos ( p o s / 1000 0 2 i / d m o d e l ) \begin{gathered} P E_{(p o s, 2 i)}=\sin \left(p o s / 10000^{2 i / d_{\mathrm{model}}}\right) \\ P E_{(p o s, 2 i+1)}=\cos \left(p o s / 10000^{2 i / d_{\mathrm{model}}}\right) \end{gathered} PE(pos,2i)=sin(pos/100002i/dmodel)PE(pos,2i+1)=cos(pos/100002i/dmodel)
其中 p o s pos pos是位置, i i i是维度。也就是说,位置编码的每个维度对应于一个正弦曲线。这些波长形成一个从 2 π 2\pi 2π到 10000 ⋅ 2 π 10000 \cdot 2\pi 10000⋅2π的集合级数。我们选择这个函数是因为我们假设它会让模型很容易学习到相对位置,因为对任意确定的偏移 k k k, P E p o s + k PE_{pos+k} PEpos+k可以表示为 P E p o s PE_{pos} PEpos的线性函数。
此外,我们会将编码器和解码器堆栈中的embedding和位置编码的和再加一个dropout。对于基本模型,我们使用的dropout比例是 P d r o p = 0.1 P_{drop}=0.1 Pdrop=0.1。
class PositionalEncoding(nn.Module):def __init__(self, d_model, dropout, max_len=5000):super().__init__()self.dropout = nn.Dropout(p=dropout)pe = torch.zeros(max_len, d_model)position = torch.arange(0, max_len).unsqueeze(1)div_term = torch.exp(torch.arange(0, d_model, 2) * -(math.log(10000.0) / d_model))pe[:, 0::2] = torch.sin(position * div_term)pe[:, 1::2] = torch.cos(position * div_term)pe = pe.unsqueeze(0)self.register_buffer('pe', pe)def forward(self, x):x = x + Variable(self.pe[:, :x.size(1)], requires_grad=False)return self.dropout(x)
如下图,位置编码将根据位置添加正弦波。波的频率和偏移对于每个维度都是不同的。
plt.figure(figsize=(15, 5))
pe = PositionalEncoding(20, 0)
y = pe.forward(Variable(torch.zeros(1, 100, 20)))
plt.plot(np.arange(100), y[0, :, 4:8].data.numpy())
plt.legend(["dim %d" % p for p in [4,5,6,7]])
None
完整模型
在这里,我们定义了一个从超参数到完整模型的函数。
def make_model(src_vocab, tgt_vocab, N=6, d_model=512, d_ff=2048, h=8, dropout=0.1):c = copy.deepcopyattn = MultiHeadedAttention(h, d_model)ff = PositionwiseFeedForward(d_model, d_ff, dropout)position = PositionalEncoding(d_model, dropout)model = EncoderDecoder(Encoder(EncoderLayer(d_model, c(attn), c(ff), dropout), N),Decoder(DecoderLayer(d_model, c(attn), c(attn), c(ff), dropout), N),nn.Sequential(Embeddings(d_model, src_vocab), c(position)),nn.Sequential(Embeddings(d_model, tgt_vocab), c(position)),Generator(d_model, tgt_vocab))for p in model.parameters():if p.dim() > 1:nn.init.xavier_uniform(p)return model
训练
首先,我们定义一个批处理对象,其中包含用于训练的src和目标句子,以及构建掩码。
批处理和掩码
class Batch:def __init__(self, src, trg=None, pad=0):self.src = srcself.src_mask = (src != pad).unsqueeze(-2)if trg is not None:self.trg = trg[:, :-1]self.trg_y = trg[:, 1:]self.trg_mask = self.make_std_mask(self.trg, pad)self.ntokens = (self.trg_y != pad).data.sum()@staticmethoddef make_std_mask(tgt, pad):tgt_mask = (tgt != pad).unsqueeze(-2)tgt_mask = tgt_mask & Variable(subsequent_mask(tgt.size(-1)).type_as(tgt_mask.data))return tgt_mask
Batch
类接受源序列src
和可选的目标序列trg
,以及一个用于填充的标记pad=0
,self.src_mask
用于标识src
中哪些位置是实际数据,trg_mask
是用静态方法self.make_std_mask(tgt, pad)
生成的,首先创建一个基本的掩码,指示填充位置,然后使用subsequent_mask
函数生成后续掩码,以确保在处理序列数据时不会泄露未来的信息。ntokens
属性用于计算目标数据中非填充标记的数量,这通常用于计算模型在训练过程中处理的总单词数。
接下来我们创建一个通用的训练和评估函数来跟踪损失。我们传入一个通用的损失函数,也用它来进行参数更新。
Training Loop
def run_epoch(data_iter, model, loss_compute):start = time.time()total_tokens = 0total_loss = 0tokens = 0for i, batch in enumerate(data_iter):out = model.forward(batch.src, batch.trg, batch.src_mask, batch.trg_mask)loss = loss_compute(out, batch.trg_y, batch.ntokens)total_loss += losstotal_tokens += batch.ntokenstokens += batch.ntokensif i % 50 == 1:elapsed = time.time() - startprint("Epoch Step: %d Loss : %f Tokens per Sec: %f" %(i, loss / batch.ntokens, tokens / elapsed))start = time.time()tokens = 0return total_loss / total_tokens
训练数据和批处理
我们在包含约405万个句子对的标准WMT 2014英语-德语数据集上进行了训练。这些句子使用字节对编码进行编码,源语句和目标语句共享大约37000个token的词汇表。对于英语-法语翻译,我们使用了明显更大的WMT 2014英语-法语数据集,该数据集由 3600 万个句子组成,并将token拆分为32000个word-piece词表。
每个训练批次包含一组句子对,句子对按相近序列长度来分批处理。每个训练批次的句子对包含大约25000个源语言的tokens和25000个目标语言的tokens。
global max_src_in_batch, max_tgt_in_batch
def batch_size_fn(new, count, sofar):"Keep augmenting batch and calculate total number of tokens + padding."global max_src_in_batch, max_tgt_in_batchif count == 1:max_src_in_batch = 0max_tgt_in_batch = 0max_src_in_batch = max(max_src_in_batch, len(new.src))max_tgt_in_batch = max(max_tgt_in_batch, len(new.trg) + 2)src_elements = count * max_src_in_batchtgt_elements = count * max_tgt_in_batchreturn max(src_elements, tgt_elements)
硬件和训练时间
我们在一台配备8个 NVIDIA P100 GPU 的机器上训练我们的模型。使用论文中描述的超参数的base models,每个训练step大约需要0.4秒。我们对base models进行了总共10万steps或12小时的训练。而对于big models,每个step训练时间为1.0秒,big models训练了30万steps(3.5 天)。
Optimizer
我们使用Adam优化器,其中 β 1 = 0.9 , β 2 = 0.98 \beta_1 = 0.9, \beta_2 = 0.98 β1=0.9,β2=0.98并且 ϵ = 1 0 − 9 \epsilon = 10^{-9} ϵ=10−9。我们根据以下公式在训练过程中改变学习率:
lrate = d model − 0.5 ⋅ min ( step _ num − 0.5 , step _ num ⋅ warmup _ steps − 1.5 ) \text { lrate }=d_{\text {model }}^{-0.5} \cdot \min \left(\text { step } \_ \text {num }{ }^{-0.5}, \text { step } \_ \text {num } \cdot \text { warmup } \_ \text {steps }{ }^{-1.5}\right) lrate =dmodel −0.5⋅min( step _num −0.5, step _num ⋅ warmup _steps −1.5)
这对应于在第一次 w a r m u p s t e p s warmup_steps warmupsteps步中线性地增加学习率,并且随后将其与步数的平方根成比例地减小。我们使用 w a r m u p s t e p s = 4000 warmup_steps=4000 warmupsteps=4000。
class NoamOpt:"Optim wrapper that implements rate."def __init__(self, model_size, factor, warmup, optimizer):self.optimizer = optimizerself._step = 0self.warmup = warmupself.factor = factorself.model_size = model_sizeself._rate = 0def step(self):"Update parameters and rate"self._step += 1rate = self.rate()for p in self.optimizer.param_groups:p['lr'] = rateself._rate = rateself.optimizer.step()def rate(self, step = None):"Implement `lrate` above"if step is None:step = self._stepreturn self.factor * \(self.model_size ** (-0.5) *min(step ** (-0.5), step * self.warmup ** (-1.5)))def get_std_opt(model):return NoamOpt(model.src_embed[0].d_model, 2, 4000,torch.optim.Adam(model.parameters(), lr=0, betas=(0.9, 0.98), eps=1e-9))
以下是此模型针对不同模型大小和优化超参数的曲线示例。
opts = [NoamOpt(512, 1, 4000, None),NoamOpt(512, 1, 8000, None),NoamOpt(256, 1, 4000, None)]
plt.plot(np.arange(1, 20000), [[opt.rate(i) for opt in opts] for i in range(1, 20000)])
plt.legend(["512:4000", "512:8000", "256:4000"])
None
正则化
标签平滑
在训练过程中,我们使用的label平滑的值为 ϵ l s = 0.1 \epsilon_{ls}=0.1 ϵls=0.1。虽然对label进行平滑会让模型困惑,但提高了准确性和BLEU得分。
我们使用KL div损失实现标签平滑。我们没有使用one-hot独热分布,而是创建了一个分布,该分布设定目标分布为1-smoothing,将剩余概率分配给词表中的其他单词。
class LabelSmoothing(nn.Module):def __init__(self, size, padding_idx, smoothing=0.0):super().__init__()self.criterion = nn.KLDivLoss(size_average=False)self.padding_idx = padding_idxself.confidence = 1.0 - smoothingself.smoothing = smoothingself.size = sizeself.true_dist = Nonedef forward(self, x, target):assert x.size(1) == self.sizetrue_dist = x.data.clone()true_dist.fill_(self.smoothing / (self.size - 2))target = target.data.long()true_dist.scatter_(1, target.data.unsqueeze(1), self.confidence)true_dist[:, self.padding_idx] = 0mask = torch.nonzero(target.data == self.padding_idx)if mask.dim() > 0:true_dist.index_fill_(0, mask.squeeze(), 0.0)self.true_dist = true_distreturn self.criterion(x, Variable(true_dist, requires_grad=False))
下面我们看一个例子,看看平滑后的真实概率分布。
# example of label smoothing
crit = LabelSmoothing(5, 0, 0.4)
predict = torch.FloatTensor([[0, 0.2, 0.7, 0.1, 0],[0, 0.2, 0.7, 0.1, 0],[0, 0.2, 0.7, 0.1, 0]])
v = crit(Variable(predict.log()), Variable(torch.LongTensor([2, 1, 0])))plt.imshow(crit.true_dist)
None
print(crit.true_dist)
由于标签平滑的存在,如果模型对于某个单词特别有信心,输出特别大的概率,会被惩罚。如下代码所示,随着输入x的增大,x/d会越来越大,1/d会越来越小,但是loss并不是一直降低的。
crit = LabelSmoothing(5, 0, 0.1)
def loss(x):d = x + 3 * 1predict = torch.FloatTensor([[0, x / d, 1 / d, 1 / d, 1 / d],]) + 1e-10# print(Variable(predict.log()), Variable(torch.LongTensor([1])))return crit(Variable(predict.log()), Variable(torch.LongTensor([1]))).item()y = [loss(x) for x in range(1, 100)]
x = np.arange(1, 100)
plt.plot(x, y)
实例
我们可以从尝试一个简单的复制任务开始。给定来自小词汇表的一组随机输入符号symbols,目标是生成这些相同的符号。
合成数据
def data_gen(V, batch, nbatches):for i in range(nbatches):data = torch.from_numpy(np.random.randint(1, V, size=(batch, 10)))data[:, 0] = 1src = Variable(data, requires_grad=False)tgt = Variable(data, requires_grad=False)yield Batch(src, tgt, 0)
损失函数计算
class SimpleLossCompute:def __init__(self, generator, criterion, opt=None):self.generator = generatorself.criterion = criterionself.opt = optdef __call__(self, x, y, norm):x = self.generator(x)loss = self.criterion(x.contiguous().view(-1, x.size(-1)),y.contiguous().view(-1)) / normloss.backward()if self.opt is not None:self.opt.step()self.opt.optimizer.zero_grad()return loss.item() * norm
贪婪解码
V = 11
criterion = LabelSmoothing(size=V, padding_idx=0, smoothing=0.0)
model = make_model(V, V, N=2)
model_opt = NoamOpt(model.src_embed[0].d_model, 1, 400,torch.optim.Adam(model.parameters(), lr=0, betas=(0.9, 0.98), eps=1e-9))for epoch in range(10):model.train()run_epoch(data_gen(V, 30, 20), model,SimpleLossCompute(model.generator, criterion, model_opt))model.eval()print(run_epoch(data_gen(V, 30, 5), model,SimpleLossCompute(model.generator, criterion, None)))
为了简单起见,此代码使用贪婪解码来预测翻译。
def greedy_decode(model, src, src_mask, max_len, start_symbol):memory = model.encode(src, src_mask)ys = torch.ones(1, 1).fill_(start_symbol).type_as(src.data)for i in range(max_len-1):out = model.decode(memory, src_mask, Variable(ys),Variable(subsequent_mask(ys.size(1)).type_as(src.data)))prob = model.generator(out[:, -1])_, next_word = torch.max(prob, dim = 1)next_word = next_word.data[0]ys = torch.cat([ys, torch.ones(1, 1).type_as(src.data).fill_(next_word)], dim=1)return ysmodel.eval()
src = Variable(torch.LongTensor([[1,2,3,4,5,6,7,8,9,10]]))
src_mask = Variable(torch.ones(1, 1, 10))
print(greedy_decode(model, src, src_mask, max_len=10, start_symbol=1))
维度变换
以上述例子为例,我们取batch_size=30,src_length=10,tgt_length=10
,在训练过程中,将目标序列的最后一个元素移除,这样0:8
是当前步骤的输出依赖的之前步骤的输出,而1:9
是当前步骤的输出的标签。我们来看一下维度的变化。
-
EncoderDecoder
输入给EncoderDecoder
的src.shape=torch.Size([30, 10])
,tgt_shape=torch.Size([30, 9])
,src_mask.shape=torch.Size([30, 1, 10])
,tgt_mask.shape=torch.Size([30, 9, 9])
。 -
Encoder
-
Embeddings的输入是
[30, 10]
,输出是[30, 10, 512]
,将每个词变成了嵌入向量。 -
PositionalEncoding的输入是
[30, 10, 512]
,其中的pe维度是[1, 5000, 512]
,输出是[30, 10, 512]
,将位置编码添加到了嵌入向量里,不改变维度。 -
Encoder=Encoderlayer * 6=(SublayerConnection * 2) * 6=((MultiHeadedAttention + norm + resnet) + (FFN + norm + resnet)) * 6,最终的输出维度是
[30, 10, 512]
。 -
在MutiHeadedAttention中,输入的
q,k,v
是[30, 10, 512]
,经过线性变换以后根据head=8
进行分割[30,8,10,64]
,其中self_attn.shape=[30,10,10]
,最后将得分进行合并仍是[30, 10, 512]
。 -
Decoder
-
Embeddings的输入是
[30, 9]
,输出是[30, 9, 512]
,将每个词变成了嵌入向量,Positional同Encoder,最终输出是[30, 9, 512]
。 -
Decoder=Decoderlayer * 6=(SublayerConnection * 2) * 6=((MultiHeadedAttention1 + norm + resnet) + (MultiHeadedAttention2 + norm + resnet) + (FFN + norm + resnet)) * 6,最终的输出维度是
[30, 9, 512]
。这里第一个多头注意力是自注意力,所以输入输出都是[30, 9, 512]
,第二个是q
来自之前的输出[30, 9, 512],k,v
来自memory[30, 10, 512]
,最终输出[30, 9, 512]
。 -
在上述例子中,首先用
start_symbol=1
作为decoder输入,用[1,2,3,4,5,6,7,8,9,10]
作为源序列,src_mask
全为1表示源序列所有位置都有效。简单的测试了一下用这个结构进行编解码。
真实场景示例
由于原始的教程的真实数据场景需要多GPU训练,所以这里仅使用合成的数据对其进行了训练和预测。