Transformer模型简要分析(下篇)(代码来源D2l)

Transformer模型简要分析(下篇)(代码来源D2l)

ps:最后一篇,写完下播:)

Transformer模型简要分析(上篇)(代码来源D2l)
Transformer模型简要分析(中篇)(代码来源D2l)

在前面简要说明了bahdanau机制的构成,其通过在seq2seq中引入注意力机制来提高模型的bleu分数,下面说明多头注意力机制、自注意力和位置编码,以及本篇博客一直围绕的核心内容:transformer

7.多头注意力机制

多头注意力机制其实质是应用多个注意力机制模型来共同构建一个输出向量,其中多个注意力机制对应了多个注意力头,然后每个注意力头的查询,键,值可以是 (batch_size,查询或者“键-值”对的个数,num_hiddens)形状,即一个头可以完成多个长为num_hiddens的查询向量的多次查询,然后再将这些查询汇聚并输出。
在实践中,当给定相同的查询、键和值的集合时, 我们希望模型可以基于相同的注意力机制学习到不同的行为, 然后将不同的行为作为知识组合起来, 捕获序列内各种范围的依赖关系 (例如,短距离依赖和长距离依赖关系)。 因此,允许注意力机制组合使用查询、键和值的不同 子空间表示(representation subspaces)可能是有益的。
图片引用来源:https://zh-v2.d2l.ai/chapter_attention-mechanisms/multihead-attention.html
在这里插入图片描述

为此,与其只使用单独一个注意力汇聚, 我们可以用独立学习得到的 ℎ组不同的线性投影(linear projections)来变换查询、键和值。 然后,这ℎ组变换后的查询、键和值将并行地送到注意力汇聚中。 最后,将这ℎ个注意力汇聚的输出拼接在一起, 并且通过另一个可以学习的线性投影进行变换, 以产生最终输出。 这种设计被称为多头注意力(multihead attention)Vaswani.Shazeer.Parmar.ea.2017。 对于 ℎ个注意力汇聚输出,每一个注意力汇聚都被称作一个头(head)。(引用源至D2l)

7.1模型

给定查询 q ∈ R d q \mathbf{q} \in \mathbb{R}^{d_q} qRdq、键 k ∈ R d k \mathbf{k} \in \mathbb{R}^{d_k} kRdk和值 v ∈ R d v \mathbf{v} \in \mathbb{R}^{d_v} vRdv,每个注意力头 h i \mathbf{h}_i hi i = 1 , … , h i = 1, \ldots, h i=1,,h)的计算方法为:
h i = f ( W i ( q ) q , W i ( k ) k , W i ( v ) v ) ∈ R p v , \mathbf{h}_i = f(\mathbf W_i^{(q)}\mathbf q, \mathbf W_i^{(k)}\mathbf k,\mathbf W_i^{(v)}\mathbf v) \in \mathbb R^{p_v}, hi=f(Wi(q)q,Wi(k)k,Wi(v)v)Rpv,
其中,可学习的参数包括 W i ( q ) ∈ R p q × d q \mathbf W_i^{(q)}\in\mathbb R^{p_q\times d_q} Wi(q)Rpq×dq W i ( k ) ∈ R p k × d k \mathbf W_i^{(k)}\in\mathbb R^{p_k\times d_k} Wi(k)Rpk×dk W i ( v ) ∈ R p v × d v \mathbf W_i^{(v)}\in\mathbb R^{p_v\times d_v} Wi(v)Rpv×dv,以及代表注意力汇聚的函数 f f f f f f可以是加性注意力和缩放点积注意力。多头注意力的输出需要经过另一个线性转换,它对应着 h h h个头连结后的结果,因此其可学习参数是 W o ∈ R p o × h p v \mathbf W_o\in\mathbb R^{p_o\times h p_v} WoRpo×hpv

W o [ h 1 ⋮ h h ] ∈ R p o . \mathbf W_o \begin{bmatrix}\mathbf h_1\\\vdots\\\mathbf h_h\end{bmatrix} \in \mathbb{R}^{p_o}. Wo h1hh Rpo.

基于这种设计,每个头都可能会关注输入的不同部分(引用源至D2l)。

简单来说,通过组合查询与键值对的特征空间来使得每个注意力头关注不同的内容,然后使用W0来将这些所有的关注组合输出。

模型代码:

#@save
class MultiHeadAttention(nn.Module):
    """多头注意力"""
    def __init__(self, key_size, query_size, value_size, num_hiddens,
                 num_heads, dropout, bias=False, **kwargs):
        super(MultiHeadAttention, self).__init__(**kwargs)
        self.num_heads = num_heads
        self.attention = d2l.DotProductAttention(dropout)
        self.W_q = nn.Linear(query_size, num_hiddens, bias=bias)
        self.W_k = nn.Linear(key_size, num_hiddens, bias=bias)
        self.W_v = nn.Linear(value_size, num_hiddens, bias=bias)
        self.W_o = nn.Linear(num_hiddens, num_hiddens, 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_qkv(self.W_q(queries), self.num_heads)
        keys = transpose_qkv(self.W_k(keys), self.num_heads)
        values = transpose_qkv(self.W_v(values), self.num_heads)
        if valid_lens is not None:
            # 在轴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)

实现过程中通常选择缩放点积注意力作为每一个注意力头。 为了避免计算代价和参数代价的大幅增长, 我们设定 𝑝𝑞=𝑝𝑘=𝑝𝑣=𝑝𝑜/ℎ。 值得注意的是,如果将查询、键和值的线性变换的输出数量设置为 𝑝𝑞ℎ=𝑝𝑘ℎ=𝑝𝑣ℎ=𝑝𝑜, 则可以并行计算 ℎ个头。 在下面的实现中, 𝑝𝑜是通过参数num_hiddens指定的。此处为了使多个头并行计算,提供两个函数补充实现如下:

#@save
def transpose_qkv(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])
#@save
def transpose_output(X, num_heads):
    """逆转transpose_qkv函数的操作"""
    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)

此处进行变换的原因是:可以把一个self.attention = d2l.DotProductAttention(dropout)看作是num_heads个小的注意力头构成,于是针对我们的给定输入,可以通过分组来达到同样的效果,即(batch_size*num_heads,查询或者“键-值”对的个数,num_hiddens/num_heads)第一维可以看作是num_heads个分别具有(batch_size,查询或者“键-值”对的个数,num_hiddens/num_heads)形状的小注意力头。

测试代码如下:

num_hiddens, num_heads = 100, 5
attention = MultiHeadAttention(num_hiddens, num_hiddens, num_hiddens,num_hiddens, 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, num_queries = 2, 4
num_kvpairs, valid_lens =  6, torch.tensor([3, 2])
X = torch.ones((batch_size, num_queries, num_hiddens))
Y = torch.ones((batch_size, num_kvpairs, num_hiddens))
attention(X, Y, Y, valid_lens).shape

torch.Size([2, 4, 100])

观察得知符合多个注意力头通过全连接层之后的向量输出形状。

8.自注意力和位置编码

考虑一个事实:在深度学习中,经常使用卷积神经网络(CNN)或循环神经网络(RNN)对序列进行编码。 想象一下,有了注意力机制之后,我们将词元序列输入注意力池化中, 以便同一组词元同时充当查询、键和值。 具体来说,每个查询都会关注所有的键-值对并生成一个注意力输出。 由于查询、键和值来自同一组输入,因此被称为 自注意力(self-attention)(Lin.Feng.Santos.ea.2017,Vaswani.Shazeer.Parmar.ea.2017),也被称为内部注意力(intra-attention)(Cheng.Dong.Lapata.2016,Parikh.Tackstrom.Das.ea.2016,Paulus.Xiong.Socher.2017)

8.1自注意力

给定一个由词元组成的输入序列 x 1 , … , x n \mathbf{x}_1, \ldots, \mathbf{x}_n x1,,xn,其中任意 x i ∈ R d \mathbf{x}_i \in \mathbb{R}^d xiRd 1 ≤ i ≤ n 1 \leq i \leq n 1in)。该序列的自注意力输出为一个长度相同的序列 y 1 , … , y n \mathbf{y}_1, \ldots, \mathbf{y}_n y1,,yn,其中:
y i = f ( x i , ( x 1 , x 1 ) , … , ( x n , x n ) ) ∈ R d \mathbf{y}_i = f(\mathbf{x}_i, (\mathbf{x}_1, \mathbf{x}_1), \ldots, (\mathbf{x}_n, \mathbf{x}_n)) \in \mathbb{R}^d yi=f(xi,(x1,x1),,(xn,xn))Rd
根据注意力汇聚函数 f f f
下面的代码片段是基于多头注意力对一个张量完成自注意力的计算,张量的形状为(批量大小,时间步的数目或词元序列的长度, d d d)。输出与输入的张量形状相同。

num_hiddens, num_heads = 100, 5
attention = d2l.MultiHeadAttention(num_hiddens, num_hiddens, num_hiddens,num_hiddens, 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, num_queries, valid_lens = 2, 4, torch.tensor([3, 2])
X = torch.ones((batch_size, num_queries, num_hiddens))
attention(X, X, X, valid_lens).shape

torch.Size([2, 4, 100])

8.2卷积神经网络、循环神经网络、自注意力机制比较

目标将由 𝑛 个词元组成的序列映射到另一个长度相等的序列,其中的每个输入词元或输出词元都由 𝑑 维向量表示。
事实 :比较的是卷积神经网络、循环神经网络和自注意力这几个架构的计算复杂性、顺序操作和最大路径长度,顺序操作会妨碍并行计算,而任意的序列位置组合之间的路径越短,则能更轻松地学习序列中的远距离依赖关系。(Hochreiter.Bengio.Frasconi.ea.2001)
图片来源:https://zh-v2.d2l.ai/chapter_attention-mechanisms/self-attention-and-positional-encoding.html
在这里插入图片描述
考虑一个卷积核大小为 𝑘 的卷积层。 在后面的章节将提供关于使用卷积神经网络处理序列的更多详细信息。 目前只需要知道的是,由于序列长度是 𝑛,输入和输出的通道数量都是 𝑑, 所以卷积层的计算复杂度为 O ( k n d 2 ) \mathcal{O}(knd^2) O(knd2)。卷积神经网络是分层的,因此为有 O ( 1 ) \mathcal{O}(1) O(1)个顺序操作,最大路径长度为 O ( n / k ) \mathcal{O}(n/k) O(n/k)。例如, x 1 \mathbf{x}_1 x1 x 5 \mathbf{x}_5 x5处于卷积核大小为3的双层卷积神经网络的感受野内。
当更新循环神经网络的隐状态时 d × d d \times d d×d权重矩阵和 d d d维隐状态的乘法计算复杂度为 O ( d 2 ) \mathcal{O}(d^2) O(d2)。由于序列长度为 n n n,因此循环神经网络层的计算复杂度为 O ( n d 2 ) \mathcal{O}(nd^2) O(nd2)。根据 :numref:fig_cnn-rnn-self-attention,有 O ( n ) \mathcal{O}(n) O(n)个顺序操作无法并行化,最大路径长度也是 O ( n ) \mathcal{O}(n) O(n)
在自注意力中,查询、键和值都是 n × d n \times d n×d矩阵。考虑 :eqref:eq_softmax_QK_V中缩放的”点-积“注意力,其中 n × d n \times d n×d矩阵乘以 d × n d \times n d×n矩阵。之后输出的 n × n n \times n n×n矩阵乘以 n × d n \times d n×d矩阵。因此,自注意力具有 O ( n 2 d ) \mathcal{O}(n^2d) O(n2d)计算复杂性。正如在 :numref:fig_cnn-rnn-self-attention中所讲,每个词元都通过自注意力直接连接到任何其他词元。因此,有 O ( 1 ) \mathcal{O}(1) O(1)个顺序操作可以并行计算,最大路径长度也是 O ( 1 ) \mathcal{O}(1) O(1)

总而言之,卷积神经网络和自注意力都拥有并行计算的优势,而且自注意力的最大路径长度最短。
但是因为其计算复杂度是关于序列长度的二次方,所以在很长的序列中计算会非常慢。
(引用源至D2l)

8.3位置编码

在处理词元序列时,循环神经网络是逐个的重复地处理词元的, 而自注意力则因为并行计算而放弃了顺序操作。 为了使用序列的顺序信息,通过在输入表示中添加位置编码(positional encoding)来注入绝对的或相对的位置信息。位置编码可以通过学习得到也可以直接固定得到,接下来描述的是基于正弦函数和余弦函数的固定位置编码。
假设输入表示 X ∈ R n × d \mathbf{X} \in \mathbb{R}^{n \times d} XRn×d包含一个序列中 n n n个词元的 d d d维嵌入表示。位置编码使用相同形状的位置嵌入矩阵 P ∈ R n × d \mathbf{P} \in \mathbb{R}^{n \times d} PRn×d输出 X + P \mathbf{X} + \mathbf{P} X+P,矩阵第 i i i行、第 2 j 2j 2j列和 2 j + 1 2j+1 2j+1列上的元素为:
p i , 2 j = sin ⁡ ( i 1000 0 2 j / d ) , p i , 2 j + 1 = cos ⁡ ( i 1000 0 2 j / d ) . \begin{aligned} p_{i, 2j} &= \sin\left(\frac{i}{10000^{2j/d}}\right),\\p_{i, 2j+1} &= \cos\left(\frac{i}{10000^{2j/d}}\right).\end{aligned} pi,2jpi,2j+1=sin(100002j/di),=cos(100002j/di).

#@save
#为输入X添加位置编码信息
class PositionalEncoding(nn.Module):
    """位置编码"""
    def __init__(self, num_hiddens, dropout, max_len=1000):
        super(PositionalEncoding, self).__init__()
        self.dropout = nn.Dropout(dropout)
        # 创建一个足够长的P
        self.P = torch.zeros((1, max_len, num_hiddens))
        #创建一个预先保存的P位置信息矩阵
        X = torch.arange(max_len, dtype=torch.float32).reshape(-1, 1) / torch.pow(10000, torch.arange( 0, num_hiddens, 2, dtype=torch.float32) / num_hiddens)
        self.P[:, :, 0::2] = torch.sin(X)
        self.P[:, :, 1::2] = torch.cos(X)
    def forward(self, X):
    	#将输入的张量矩阵添加上位置信息
        X = X + self.P[:, :X.shape[1], :].to(X.device)
        return self.dropout(X)

#测试代码
encoding_dim, num_steps = 32, 60
pos_encoding = PositionalEncoding(encoding_dim, 0)
pos_encoding.eval()
X = pos_encoding(torch.zeros((1, num_steps, encoding_dim)))
P = pos_encoding.P[:, :X.shape[1], :]
print(P[0, :, 6:10])
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)])

在位置嵌入矩阵 𝐏 中, 行代表词元在序列中的位置,列代表位置编码的不同维度。 从下面的例子中可以看到位置嵌入矩阵的第 6 列和第 7 列的频率高于第 8 列和第 9 列。 第 6 列和第 7 列之间的偏移量(第 8 列和第 9 列相同)是由于正弦函数和余弦函数的交替。

tensor([[ 0.0000e+00, 1.0000e+00, 0.0000e+00, 1.0000e+00],
[ 1.7689e-01, 9.8423e-01, 9.9833e-02, 9.9500e-01],
[ 3.4821e-01, 9.3742e-01, 1.9867e-01, 9.8007e-01],
[ 5.0854e-01, 8.6104e-01, 2.9552e-01, 9.5534e-01],
[ 6.5283e-01, 7.5751e-01, 3.8942e-01, 9.2106e-01],
[ 7.7653e-01, 6.3008e-01, 4.7943e-01, 8.7758e-01],
[ 8.7574e-01, 4.8278e-01, 5.6464e-01, 8.2534e-01],
[ 9.4733e-01, 3.2026e-01, 6.4422e-01, 7.6484e-01],
[ 9.8904e-01, 1.4763e-01, 7.1736e-01, 6.9671e-01],
[ 9.9956e-01, -2.9651e-02, 7.8333e-01, 6.2161e-01],
[ 9.7855e-01, -2.0600e-01, 8.4147e-01, 5.4030e-01],
[ 9.2668e-01, -3.7585e-01, 8.9121e-01, 4.5360e-01],
[ 8.4558e-01, -5.3384e-01, 9.3204e-01, 3.6236e-01],
[ 7.3782e-01, -6.7500e-01, 9.6356e-01, 2.6750e-01],
[ 6.0678e-01, -7.9487e-01, 9.8545e-01, 1.6997e-01],
[ 4.5660e-01, -8.8967e-01, 9.9749e-01, 7.0737e-02],
[ 2.9203e-01, -9.5641e-01, 9.9957e-01, -2.9200e-02],
[ 1.1824e-01, -9.9299e-01, 9.9166e-01, -1.2884e-01],
[-5.9276e-02, -9.9824e-01, 9.7385e-01, -2.2720e-01],
[-2.3492e-01, -9.7201e-01, 9.4630e-01, -3.2329e-01],
[-4.0316e-01, -9.1513e-01, 9.0930e-01, -4.1615e-01],
[-5.5868e-01, -8.2938e-01, 8.6321e-01, -5.0485e-01],
[-6.9658e-01, -7.1748e-01, 8.0850e-01, -5.8850e-01],
[-8.1251e-01, -5.8294e-01, 7.4571e-01, -6.6628e-01],
[-9.0282e-01, -4.3002e-01, 6.7546e-01, -7.3739e-01],
[-9.6465e-01, -2.6354e-01, 5.9847e-01, -8.0114e-01],
[-9.9605e-01, -8.8746e-02, 5.1550e-01, -8.5689e-01],
[-9.9605e-01, 8.8848e-02, 4.2738e-01, -9.0407e-01],
[-9.6462e-01, 2.6364e-01, 3.3499e-01, -9.4222e-01],
[-9.0277e-01, 4.3012e-01, 2.3925e-01, -9.7096e-01],
[-8.1245e-01, 5.8303e-01, 1.4112e-01, -9.8999e-01],
[-6.9651e-01, 7.1755e-01, 4.1581e-02, -9.9914e-01],
[-5.5860e-01, 8.2944e-01, -5.8374e-02, -9.9829e-01],
[-4.0306e-01, 9.1517e-01, -1.5775e-01, -9.8748e-01],
[-2.3482e-01, 9.7204e-01, -2.5554e-01, -9.6680e-01],
[-5.9173e-02, 9.9825e-01, -3.5078e-01, -9.3646e-01],
[ 1.1834e-01, 9.9297e-01, -4.4252e-01, -8.9676e-01],
[ 2.9213e-01, 9.5638e-01, -5.2984e-01, -8.4810e-01],
[ 4.5670e-01, 8.8962e-01, -6.1186e-01, -7.9097e-01],
[ 6.0686e-01, 7.9481e-01, -6.8777e-01, -7.2593e-01],
[ 7.3789e-01, 6.7493e-01, -7.5680e-01, -6.5364e-01],
[ 8.4564e-01, 5.3376e-01, -8.1828e-01, -5.7482e-01],
[ 9.2672e-01, 3.7575e-01, -8.7158e-01, -4.9026e-01],
[ 9.7857e-01, 2.0590e-01, -9.1617e-01, -4.0080e-01],
[ 9.9956e-01, 2.9548e-02, -9.5160e-01, -3.0733e-01],
[ 9.8903e-01, -1.4773e-01, -9.7753e-01, -2.1080e-01],
[ 9.4730e-01, -3.2035e-01, -9.9369e-01, -1.1215e-01],
[ 8.7569e-01, -4.8287e-01, -9.9992e-01, -1.2389e-02],
[ 7.7646e-01, -6.3016e-01, -9.9616e-01, 8.7499e-02],
[ 6.5275e-01, -7.5757e-01, -9.8245e-01, 1.8651e-01],
[ 5.0845e-01, -8.6109e-01, -9.5892e-01, 2.8366e-01],
[ 3.4811e-01, -9.3745e-01, -9.2581e-01, 3.7798e-01],
[ 1.7679e-01, -9.8425e-01, -8.8345e-01, 4.6852e-01],
[-1.0302e-04, -1.0000e+00, -8.3227e-01, 5.5437e-01],
[-1.7699e-01, -9.8421e-01, -7.7276e-01, 6.3469e-01],
[-3.4830e-01, -9.3738e-01, -7.0554e-01, 7.0867e-01],
[-5.0863e-01, -8.6099e-01, -6.3127e-01, 7.7557e-01],
[-6.5291e-01, -7.5744e-01, -5.5069e-01, 8.3471e-01],
[-7.7660e-01, -6.3000e-01, -4.6460e-01, 8.8552e-01],
[-8.7579e-01, -4.8269e-01, -3.7388e-01, 9.2748e-01]])

在这里插入图片描述

8.3.1绝对位置信息

为了明白沿着编码维度单调降低的频率与绝对位置信息的关系, 打印出 0,1,…,7 的二进制表示形式。每个数字、每两个数字和每四个数字上的比特值在第一个最低位、第二个最低位和第三个最低位上分别交替。

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

在二进制表示中,较高比特位的交替频率低于较低比特位, 与下面的热图所示相似,只是位置编码通过使用三角函数在编码维度上降低频率。 位置编码,它通过使用三角函数在编码维度上降低频率。位置编码在表示数字时使用了连续的浮点数,而不是二进制的离散数。浮点数可以表示更多的精度和范围,因此在一定的位数下,可以表示更多的数字,节省了空间。 由于输出是浮点数,因此此类连续表示比二进制表示法更节省空间。(引用源至D2l)

如何理解位置编码与绝对位置的关系?对比二进制可知,二进制从0~7的绝对位置顺序通过其二进制编码体现了出来,即000->001->010等等,同时在其三个维度上表现出的信息是随着维度增大,维度的易变性(频率)变得越来越高,这就将绝对位置信息编码到了三个维度上,相较于整数二进制的编码而言,使用三角函数编码可以更好地表示位置信息(由于其连续性),同时由于在相同计算机存储位数前提下,浮点数可表示的范围明显大于整数,故可以表示更多的数字,同时也节省了空间。

给出位置编码的热值图关系如下所示(热值图注意是反的,固定ROW时从右到左对应了一个编码向量的左到右):

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

在这里插入图片描述

8.3.2相对位置信息

除了捕获绝对位置信息之外,上述的位置编码还允许模型学习得到输入序列中相对位置信息。 这是因为对于任何确定的位置偏移 𝛿,位置 𝑖+𝛿 处的位置编码可以线性投影位置 𝑖 处的位置编码来表示。
数学解释如下:
ω j = 1 / 1000 0 2 j / d \omega_j = 1/10000^{2j/d} ωj=1/100002j/d,对于任何确定的位置偏移 δ \delta δ
上述提到的 ( p i , 2 j , p i , 2 j + 1 ) (p_{i, 2j}, p_{i, 2j+1}) (pi,2j,pi,2j+1)都可以线性投影到 ( p i + δ , 2 j , p i + δ , 2 j + 1 ) (p_{i+\delta, 2j}, p_{i+\delta, 2j+1}) (pi+δ,2j,pi+δ,2j+1)

[ cos ⁡ ( δ ω j ) sin ⁡ ( δ ω j ) − sin ⁡ ( δ ω j ) cos ⁡ ( δ ω j ) ] [ p i , 2 j p i , 2 j + 1 ] = [ cos ⁡ ( δ ω j ) sin ⁡ ( i ω j ) + sin ⁡ ( δ ω j ) cos ⁡ ( i ω j ) − sin ⁡ ( δ ω j ) sin ⁡ ( i ω j ) + cos ⁡ ( δ ω j ) cos ⁡ ( i ω j ) ] = [ sin ⁡ ( ( i + δ ) ω j ) cos ⁡ ( ( i + δ ) ω j ) ] = [ p i + δ , 2 j p i + δ , 2 j + 1 ] , \begin{aligned} &\begin{bmatrix} \cos(\delta \omega_j) & \sin(\delta \omega_j) \\ -\sin(\delta \omega_j) & \cos(\delta \omega_j) \\ \end{bmatrix} \begin{bmatrix} p_{i, 2j} \\ p_{i, 2j+1} \\ \end{bmatrix}\\ =&\begin{bmatrix} \cos(\delta \omega_j) \sin(i \omega_j) + \sin(\delta \omega_j) \cos(i \omega_j) \\ -\sin(\delta \omega_j) \sin(i \omega_j) + \cos(\delta \omega_j) \cos(i \omega_j) \\ \end{bmatrix}\\ =&\begin{bmatrix} \sin\left((i+\delta) \omega_j\right) \\ \cos\left((i+\delta) \omega_j\right) \\ \end{bmatrix}\\ =& \begin{bmatrix} p_{i+\delta, 2j} \\ p_{i+\delta, 2j+1} \\ \end{bmatrix}, \end{aligned} ===[cos(δωj)sin(δωj)sin(δωj)cos(δωj)][pi,2jpi,2j+1][cos(δωj)sin(iωj)+sin(δωj)cos(iωj)sin(δωj)sin(iωj)+cos(δωj)cos(iωj)][sin((i+δ)ωj)cos((i+δ)ωj)][pi+δ,2jpi+δ,2j+1],
2 × 2 2\times 2 2×2投影矩阵不依赖于任何位置的索引 i i i
在给定的公式中, ω j \omega_j ωj代表一个与 j j j相关的数值。具体而言,它是一个根据参数 d d d j j j计算得出的值。符号解释如下:

  • d d d:是一个常数,代表着问题或计算中的某种尺度或维度。
  • j j j:是一个整数,用于计算 ω j \omega_j ωj的中间变量。
  • ω j \omega_j ωj:是一个与 d d d j j j相关的数值。根据公式 ω j = 1 / 1000 0 2 j / d \omega_j = 1/10000^{2j/d} ωj=1/100002j/d计算得出,它随着 j j j的增加而递减。

换句话说, ω j \omega_j ωj可以被看作是一个与问题或计算中特定维度或尺度相关的权重。它通过指数函数的形式,对于不同的 j j j值产生不同的权重大小。 j j j越大,对应的 ω j \omega_j ωj就越小。因此, ω j \omega_j ωj可以用来表示在计算过程中对不同尺度或维度的关注程度或重要性。在给定的公式中,通过使用 ω j \omega_j ωj来计算矩阵的元素,可以实现旋转和变换的效果。这样的变换能够在处理信号、图像处理等领域中发挥重要作用,同时也可以用于其他数学和计算领域。

9.Transformer

首先,恭喜看到此处的你!经过前面的一步步铺垫,我们终于可以引入transformer的相关内容说明,即使我们已经使用了较长的篇幅来为说明transformer做铺垫(实际是我们还是略过了大部分的内容,比如,假设读者已经具有了循环神经网络的相关知识等,而它们在本文中并未详细提及),鉴于此,请继续阅读下去,这将是我们最后的一场战争!在此感谢D2l全体相关人员所作的工作,因为他们的付出才有了本篇文章的诞生。

Transformer模型完全基于注意力机制,没有任何卷积层或循环神经网络层 (Vaswani et al., 2017)。尽管Transformer最初是应用于在文本数据上的序列到序列学习,但现在已经推广到各种现代的深度学习中,例如语言、视觉、语音和强化学习领域。(D2l)

9.1模型概述

图片来源:https://zh-v2.d2l.ai/chapter_attention-mechanisms/transformer.html
在这里插入图片描述
与基于Bahdanau注意力实现的序列到序列的学习相比,Transformer的编码器和解码器是基于自注意力的模块叠加而成的,源(输入)序列和目标(输出)序列的嵌入(embedding)表示将加上位置编码(positional encoding),再分别输入到编码器和解码器中。为了使transformer具有一个很好的效果,研究人员使用了已经经过验证的先验约束,如规范化机制、残差连接等等,对于此部分不清楚的朋友请参考《Deep Learning 深度学习》花书进一步的阅读。

从宏观角度来看,Transformer的编码器是由多个相同的层叠加而成的,每个层都有两个子层(子层表示为 s u b l a y e r \mathrm{sublayer} sublayer)。第一个子层是多头自注意力(multi-head self-attention)汇聚;第二个子层是基于位置的前馈网络(positionwise feed-forward network)。具体来说,在计算编码器的自注意力时,查询、键和值都来自前一个编码器层的输出。受残差网络的启发,每个子层都采用了残差连接(residual connection)。在Transformer中,对于序列中任何位置的任何输入 x ∈ R d \mathbf{x} \in \mathbb{R}^d xRd,都要求满足 s u b l a y e r ( x ) ∈ R d \mathrm{sublayer}(\mathbf{x}) \in \mathbb{R}^d sublayer(x)Rd,以便残差连接满足 x + s u b l a y e r ( x ) ∈ R d \mathbf{x} + \mathrm{sublayer}(\mathbf{x}) \in \mathbb{R}^d x+sublayer(x)Rd。在残差连接的加法计算之后,紧接着应用层规范化(layer normalization)(Ba.Kiros.Hinton.2016)。因此,输入序列对应的每个位置,Transformer编码器都将输出一个 d d d维表示向量。

Transformer解码器也是由多个相同的层叠加而成的,并且层中使用了残差连接和层规范化。除了编码器中描述的两个子层之外,解码器还在这两个子层之间插入了第三个子层,称为编码器-解码器注意力(encoder-decoder attention)层。在编码器-解码器注意力中,查询来自前一个解码器层的输出,而键和值来自整个编码器的输出。在解码器自注意力中,查询、键和值都来自上一个解码器层的输出。但是,解码器中的每个位置只能考虑该位置之前的所有位置。这种掩蔽(masked)注意力保留了自回归(auto-regressive)属性,确保预测仅依赖于已生成的输出词元。(D2l)

9.2基于位置的前馈网络

基于位置的前馈网络对序列中的所有位置的表示进行变换时使用的是同一个多层感知机(MLP),这就是称前馈网络是基于位置的(positionwise)的原因。在下面的实现中,输入X的形状(批量大小,时间步数或序列长度,隐单元数或特征维度)将被一个两层的感知机转换成形状为(批量大小,时间步数,ffn_num_outputs)的输出张量。(D2l)

#@save
class PositionWiseFFN(nn.Module):
    """基于位置的前馈网络"""
    def __init__(self, ffn_num_input, ffn_num_hiddens, ffn_num_outputs,
                 **kwargs):
        super(PositionWiseFFN, self).__init__(**kwargs)
        self.dense1 = nn.Linear(ffn_num_input, ffn_num_hiddens)
        self.relu = nn.ReLU()
        self.dense2 = nn.Linear(ffn_num_hiddens, ffn_num_outputs)
    def forward(self, X):
        return self.dense2(self.relu(self.dense1(X)))

基于位置的前馈网络(Positionwise Feed-Forward Network)是一种常用于自然语言处理任务中的神经网络结构,它通常用于编码器-解码器模型或者自注意力模型中。该网络的输入是一个序列,比如文本中的词向量序列或者编码器中的隐藏状态序列。对于序列中的每个位置,位置编码会将该位置的信息传递给前馈神经网络进行处理。前馈神经网络由两个线性变换和激活函数组成,通常是一个两层的全连接神经网络。该网络的目的是对每个序列位置的特征进行独立的非线性变换。具体而言,对于输入序列中的每个位置,前馈神经网络会首先应用第一个线性变换,然后通过激活函数进行非线性变换,再应用第二个线性变换。这样可以对每个位置的特征进行局部的映射和调整,以提取更丰富的表示。位置编码和前馈神经网络可以形成一个循环,使得每个序列位置都能够利用前面位置的信息进行特征提取和处理。这种位置信息的传递和前馈处理可以帮助模型更好地理解序列中不同位置的语义和关系,从而提高任务的表现。

考虑在多头注意力机制之后的基于位置的前馈网络,在编码器中,嵌入层输出和位置编码信息一起传输到多头自注意力机制当中去来构建每一个xi的基于自注意力机制的输出yi,其实质是针对每一个序列xi,充分考虑其与另外的序列的基于位置上的相关特征关系,然后再将这个具有位置信息的Y输入到基于位置的前馈神经网络当中去进一步学习相应的Y中的特征:采用一个线性全连接层+激活层Relu+线性全连接层,经过上述的流程,就得到了一个较好的输入X的新的特征表示,然后将上述的操作不断重复,不断归纳(有点GCC的堆叠卷积层的味道)特征,最后得到了一个较好的表示,并将其输入给解码器。

综上所述,基于位置的前馈网络是一种在自然语言处理任务中常用的神经网络结构,用于对序列中每个位置的特征进行独立的非线性变换,以提取更丰富的表示。它能够帮助模型理解序列中不同位置的语义和关系,提高任务的性能。

下面的例子显示,改变张量的最里层维度的尺寸,会改变成基于位置的前馈网络的输出尺寸。因为用同一个多层感知机对所有位置上的输入进行变换,所以当所有这些位置的输入相同时,它们的输出也是相同的。

ffn = PositionWiseFFN(4, 4, 8)
ffn.eval()
ffn(torch.ones((2, 3, 4))).shape

torch.Size([2, 3, 8])

9.3残差连接和层规范化

加法和规范化(add&norm)组件。正如在本节开头所述,这是由残差连接和紧随其后的层规范化组成的。两者都是构建有效的深度架构的关键。层规范化和批量规范化的目标相同,但层规范化是基于特征维度进行规范化。尽管批量规范化在计算机视觉中被广泛应用,但在自然语言处理任务中(输入通常是变长序列)批量规范化通常不如层规范化的效果好。

以下代码对比不同维度的层规范化和批量规范化的效果

ln = nn.LayerNorm(2)
bn = nn.BatchNorm1d(2)
X = torch.tensor([[1, 2], [2, 3]], dtype=torch.float32)
# 在训练模式下计算X的均值和方差
print('layer norm:', ln(X), '\nbatch norm:', bn(X))

layer norm: tensor([[-1.0000, 1.0000],
[-1.0000, 1.0000]], grad_fn=<NativeLayerNormBackward0>)
batch norm: tensor([[-1.0000, -1.0000],
[ 1.0000, 1.0000]], grad_fn=<NativeBatchNormBackward0>)

可见层规范化针对特征向量维度进行了规范化:每个特征向量中的所有元素的和为0,且方差为1
可见批量规范化针对批量进行了规范化:所有的特征向量的所有元素的和为0,且方差为1

使用残差连接和层规范化来实现AddNorm类。暂退法也被作为正则化方法使用。其中,残差连接要求两个输入的形状相同,以便加法操作后输出张量的形状相同

#@save
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])

9.4编码器

基于上述组件的编码器实现如下

#@save
class EncoderBlock(nn.Module):
    """Transformer编码器块"""
    def __init__(self, key_size, query_size, value_size, num_hiddens,
                 norm_shape, ffn_num_input, ffn_num_hiddens, num_heads,
                 dropout, use_bias=False, **kwargs):
        super(EncoderBlock, self).__init__(**kwargs)
        self.attention = d2l.MultiHeadAttention(
            key_size, query_size, value_size, num_hiddens, num_heads, dropout,
            use_bias)
        self.addnorm1 = AddNorm(norm_shape, dropout)
        self.ffn = PositionWiseFFN(
            ffn_num_input, ffn_num_hiddens, num_hiddens)
        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, 24, 24, 24, [100, 24], 24, 48, 8, 0.5)
encoder_blk.eval()
encoder_blk(X, valid_lens).shape

torch.Size([2, 100, 24])

堆叠上述的block块,考虑这里使用的是值范围在 −1 和 1 之间的固定位置编码,因此通过学习得到的输入的嵌入表示的值需要先乘以嵌入维度的平方根进行重新缩放,然后再与位置编码相加。

#@save
class TransformerEncoder(d2l.Encoder):
    """Transformer编码器"""
    def __init__(self, vocab_size, key_size, query_size, value_size,
                 num_hiddens, norm_shape, ffn_num_input, ffn_num_hiddens,
                 num_heads, num_layers, dropout, use_bias=False, **kwargs):
        super(TransformerEncoder, self).__init__(**kwargs)
        self.num_hiddens = num_hiddens
        self.embedding = nn.Embedding(vocab_size, num_hiddens)
        self.pos_encoding = d2l.PositionalEncoding(num_hiddens, dropout)
        #堆叠block
        self.blks = nn.Sequential()
        for i in range(num_layers):
            self.blks.add_module("block"+str(i),
                EncoderBlock(key_size, query_size, value_size, num_hiddens,
                             norm_shape, ffn_num_input, ffn_num_hiddens,
                             num_heads, dropout, use_bias))
    def forward(self, X, valid_lens, *args):
        # 因为位置编码值在-1和1之间,
        # 因此嵌入值乘以嵌入维度的平方根进行缩放,
        # 然后再与位置编码相加。
        X = self.pos_encoding(self.embedding(X) * math.sqrt(self.num_hiddens))
        #初始化每一个堆叠块中的注意力机制
        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

模型测试:

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

两层Transformer编码器输出的形状是(批量大小,时间步数目,num_hiddens)。

torch.Size([2, 100, 24])

9.5解码器

在DecoderBlock类中实现的每个层包含了三个子层:解码器自注意力、“编码器-解码器”注意力和基于位置的前馈网络。这些子层也都被残差连接和紧随的层规范化围绕。

在掩蔽多头解码器自注意力层(第一个子层)中,查询、键和值都来自上一个解码器层的输出。关于序列到序列模型(sequence-to-sequence model),在训练阶段,其输出序列的所有位置(时间步)的词元都是已知的;然而,在预测阶段,其输出序列的词元是逐个生成的。因此,在任何解码器时间步中,只有生成的词元才能用于解码器的自注意力计算中。为了在解码器中保留自回归的属性,其掩蔽自注意力设定了参数dec_valid_lens,以便任何查询都只会与解码器中所有已经生成词元的位置(即直到该查询位置为止)进行注意力计算。(D2L)

解码器块描述如下:

class DecoderBlock(nn.Module):
    """解码器中第i个块"""
    def __init__(self, key_size, query_size, value_size, num_hiddens,
                 norm_shape, ffn_num_input, ffn_num_hiddens, num_heads,
                 dropout, i, **kwargs):
        super(DecoderBlock, self).__init__(**kwargs)
        #添加序号
        self.i = i
        #掩蔽多头自注意力
        self.attention1 = d2l.MultiHeadAttention(
            key_size, query_size, value_size, num_hiddens, num_heads, dropout)
        self.addnorm1 = AddNorm(norm_shape, dropout)
        #编码器-解码器注意力层
        self.attention2 = d2l.MultiHeadAttention(
            key_size, query_size, value_size, num_hiddens, num_heads, dropout)
        self.addnorm2 = AddNorm(norm_shape, dropout)
        #逐位前馈网络
        self.ffn = PositionWiseFFN(ffn_num_input, ffn_num_hiddens,
                                   num_hiddens)
        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_values = X
        else:
            key_values = torch.cat((state[2][self.i], X), axis=1)
        #将第i块解码器块的自己输出的状态保存在state[2][self.i]
        state[2][self.i] = key_values
        if self.training:
            batch_size, num_steps, _ = X.shape
            # dec_valid_lens的开头:(batch_size,num_steps),
            # 其中每一行是[1,2,...,num_steps]
            dec_valid_lens = torch.arange(
                1, num_steps + 1, device=X.device).repeat(batch_size, 1)
           
        else:
            dec_valid_lens = None

        # 自注意力,此处自注意力的机制的实现是通过在key_values = torch.cat((state[2][self.i], X), axis=1)中实现
        #通过每一次预测时间步,不断增加当前解码块的输出状态序列矩阵来实现保存直到当前时间步第i个块解码的输出表示
        #其中,在预测阶段时,使用当前的候选词来作为X提供键,使用状态key_values来提供查询,key_values作为值
        #进而筛选出当前候选词与整个生成序列(用状态矩阵key_values来表示的)关系,并输出到下一个解码块
        #根据训练与测试状态的不同,X的形态可能是矩阵(batch_size,),也可能是向量
        X2 = self.attention1(X, key_values, key_values, dec_valid_lens)
        Y = self.addnorm1(X, X2)
        # 编码器-解码器注意力。
        # enc_outputs的开头:(batch_size,num_steps,num_hiddens)
        Y2 = self.attention2(Y, enc_outputs, enc_outputs, enc_valid_lens)
        Z = self.addnorm2(Y, Y2)
        return self.addnorm3(Z, self.ffn(Z)), state

模型测试如下:

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

torch.Size([2, 100, 24])
torch.Size([2, 100, 24])

最终的transformer解码器基于上述组件构建如下:

class TransformerDecoder(d2l.AttentionDecoder):
    def __init__(self, vocab_size, key_size, query_size, value_size,
                 num_hiddens, norm_shape, ffn_num_input, ffn_num_hiddens,
                 num_heads, num_layers, dropout, **kwargs):
        super(TransformerDecoder, self).__init__(**kwargs)
        self.num_hiddens = num_hiddens
        self.num_layers = num_layers
        self.embedding = nn.Embedding(vocab_size, num_hiddens)
        self.pos_encoding = d2l.PositionalEncoding(num_hiddens, dropout)
        self.blks = nn.Sequential()
        for i in range(num_layers):
            self.blks.add_module("block"+str(i),
                DecoderBlock(key_size, query_size, value_size, num_hiddens,
                             norm_shape, ffn_num_input, ffn_num_hiddens,
                             num_heads, dropout, i))
        self.dense = nn.Linear(num_hiddens, vocab_size)

    def init_state(self, enc_outputs, enc_valid_lens, *args):
        #[None] * self.num_layers表示了初始化对应解码器块的个数的状态,其中[none,none]第一个none对应了第一个解码器模块的输出状态
        return [enc_outputs, enc_valid_lens, [None] * self.num_layers]

    def forward(self, X, state):
        X = self.pos_encoding(self.embedding(X) * math.sqrt(self.num_hiddens))
        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):
    """The base class for the encoder-decoder architecture.
    Defined in :numref:`sec_encoder-decoder`"""
    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)
#补充代码
def train_seq2seq(net, data_iter, lr, num_epochs, tgt_vocab, device):
    """Train a model for sequence to sequence.
    Defined in :numref:`sec_seq2seq_decoder`"""
    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)  # Sum of training loss, no. of tokens
        for batch in data_iter:
            optimizer.zero_grad()
            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 = d2l.concat([bos, Y[:, :-1]], 1)  # Teacher forcing
            Y_hat, _ = net(X, dec_input, X_valid_len)
            l = loss(Y_hat, Y, Y_valid_len)
            l.sum().backward()  # Make the loss scalar for `backward`
            d2l.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} '
          f'tokens/sec on {str(device)}')
num_hiddens, num_layers, dropout, batch_size, num_steps = 32, 2, 0.1, 64, 10
lr, num_epochs, device = 0.005, 200, d2l.try_gpu()
ffn_num_input, ffn_num_hiddens, num_heads = 32, 64, 4
key_size, query_size, value_size = 32, 32, 32
norm_shape = [32]
#提前构建好的训练数据迭代器和源词库、目标词库,参考:https://zh-v2.d2l.ai/chapter_recurrent-modern/machine-translation-and-dataset.html
train_iter, src_vocab, tgt_vocab = d2l.load_data_nmt(batch_size, num_steps)
encoder = TransformerEncoder(
    len(src_vocab), key_size, query_size, value_size, num_hiddens,
    norm_shape, ffn_num_input, ffn_num_hiddens, num_heads,
    num_layers, dropout)
decoder = TransformerDecoder(
    len(tgt_vocab), key_size, query_size, value_size, num_hiddens,
    norm_shape, ffn_num_input, ffn_num_hiddens, num_heads,
    num_layers, dropout)
net = d2l.EncoderDecoder(encoder, decoder)
#train_seq2seq代码详情参照seq2seq,是一个通用的序列到序列训练方法
d2l.train_seq2seq(net, train_iter, lr, num_epochs, tgt_vocab, device)

在这里插入图片描述

#补充一个predict_seq2seq代码后面预测会用到:
def predict_seq2seq(net, src_sentence, src_vocab, tgt_vocab, num_steps,
                    device, save_attention_weights=False):
    """Predict for sequence to sequence.
    Defined in :numref:`sec_seq2seq_training`"""
    # Set `net` to eval mode for inference
    net.eval()
    src_tokens = src_vocab[src_sentence.lower().split(' ')] + [
        src_vocab['<eos>']]
    enc_valid_len = torch.tensor([len(src_tokens)], device=device)
    src_tokens = d2l.truncate_pad(src_tokens, num_steps, src_vocab['<pad>'])
    # Add the batch axis
    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)
    # Add the batch axis
    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)
        # We use the token with the highest prediction likelihood as the input
        # of the decoder at the next time step
        dec_X = Y.argmax(dim=2)
        pred = dec_X.squeeze(dim=0).type(torch.int32).item()
        # Save attention weights (to be covered later)
        if save_attention_weights:
            attention_weight_seq.append(net.decoder.attention_weights)
        # Once the end-of-sequence token is predicted, the generation of the
        # output sequence is complete
        if pred == tgt_vocab['<eos>']:
            break
        output_seq.append(pred)
    return ' '.join(tgt_vocab.to_tokens(output_seq)), attention_weight_seq

流程分析:

在训练代码中首先初始化两个组件:encoder、decoder;训练模式的编码器首先通过代码enc_outputs = self.encoder(enc_X, *args)将数据迭代器提供的训练集X和对应的有效长度进行嵌入编码,同时输入到编码器的第一个层第一个块当中X = blk(X, valid_lens),在每个编码器层中不断进行自注意力运算最后得到一个和输入的X形状一样的输出enc_outputs,来作为 dec_state = self.decoder.init_state(enc_outputs, *args)解码器的初始化状态参数(此处的valid_lens直接运用在了自注意力机制中,故没有返回enc_valid_lens)。
解码器初始化函数中dec_state = return [enc_outputs, enc_valid_lens, [None] * self.num_layers]表示将编码器的输出和编码器有效长度(此处是None)还有根据解码器层数设定的[[None],[None],…]每一层的输入一起用于解码器初始状态。然后在TransformerDecoder的对象decoder中使用self.decoder(dec_X, dec_state)来执行decoder的forward方法:将与训练集enc_X对应的dec_X以及经过编码器编码的enc_X特征表示一起输入到decoder,在forward中首先将enc_X引入位置、嵌入编码,然后self._attention_weights = [[None] * len(self.blks) for _ in range (2)]初始化解码器中每一层的注意力权重保存列表——简单来说运用索引对应,将二维的self._attention_weights采用一列表示一个解码器层(也就是一个解码器block),一行表示某一层中的某一个注意力机制权重(行数为2,因为解码器一层有两个注意力机制),其次针对每一个解码器block执行:X, state = blk(X, state)来迭代输出,同时分别保存解码器自注意力权重 和 编码器-解码器 自注意力权重,最后使用Y_hat, _ = return self.dense(X), state完成一个批量数据的训练输出,在train_seq2seq中使用l = loss(Y_hat, Y, Y_valid_len)来衡量损失。
在解码器每一个block中做的工作描述如下,首先将编码器传入的编码张量解包enc_outputs, enc_valid_lens = state[0], state[1],因为在训练阶段每一个批量的输出序列的所有词元在同一时间处理,故state[2][self.i] is None(之后每次训练一个batch,都有Y_hat, _ = net(X, dec_input, X_valid_len),即都会重新初始化:return [enc_outputs, enc_valid_lens, [None] * self.num_layers]),紧接着判断是否是训练阶段if self.training:,即针对训练阶段来截取有效的时间步长度batch_size, num_steps, _ = X.shape,其中dec_valid_lens为了在解码器中保留自回归的属性,其掩蔽自注意力设定了参数dec_valid_lens,以便任何查询都只会与解码器中所有已经生成词元的位置(即直到该查询位置为止)进行注意力计算。
解码器训练的过程使用dec_valid_lens来达到一个自回归的效果,其实质是训练过程中的输出序列Y中的yi看到的编码器输入序列X中的内容是有限制的,具体来说,y1能够看到x1,y2则能看到x1,x2,以此类推,故dec_valid_lens的形状:(batch_size,num_steps)其中每一行是[1,2,…,num_steps],很好地表示了以解码器输入序列查询yi能够看到的长度。解码器输入形状为(batch_size,num_step,embed_size)而经过编码器编码后的输入X形状一样(batch_size,num_step,embed_size)考虑dec_valid_lens=(batch_size,num_steps)可知,取一个batch中的输入-输出序列对为例,Y=(1,num_step,embed_size)其中取y1时,其对应的dec_valid_lens为1(因为dec_valid_lens每一行都是[1,2,3,…,num_steps]),取y2时,其对应的dec_valid_lens为2,依次类推取 y n u m _ s t e p s y_{num\_steps} ynum_steps时,其对应的dec_valid_lens为num_step,这也就解释了自回归属性的实现(和seq2seq训练过程相比,seq2seq可以看到未来的信息,而transformer更加符合文本的生成规律,即看不到未来)。
解码器预测的过程则和训练差不多,此时dec_valid_lens = None,因为预测生成的过程本省就是自回归,回忆一下sed2seq中生成翻译预测序列的过程:在编码器中将原序列生成的上下文信息作为解码器的初始化状态,同时注意到在训练的过程中针对输出序列运用了强制学习(即添加‘<bos>’,'<eos>‘等符号在输入序列中),故在预测的过程我们使用‘<bos>’作为第一个生成词结合编码器提供的初始化信息来一步步生成预测词元,直到解码器生成’<eos>'为止。下面简单结合代码说明一下该过程。

在预测的过程中,首先state[2][self.i] is None成立,然后key_values = X,因为是预测模式,故dec_valid_lens = None,接下来使用X2 = self.attention1(X, key_values, key_values, 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,结束一个block中的运算。当解码器输出一个预测词元y时,y又被拿到解码器的第一个block块中进行相应的运算,流程和上述基本上一致,但state[2][self.i] is not None,此时执行key_values = torch.cat((state[2][self.i], X), axis=1)来将新的y与上一个词元预测保留的历史信息在第二维连接,即构建新的键值对并且来与y查询相运算再输出到下一个block,举个例子,假设现在有两个解码器block:0,1;其中按顺序连接,1最后连接一个全连接层产生输出,0block块将输入X进一步提取之后输给1block块,然后得到下一个词元输出(此处假设为Y),如果Y不是‘<eos>’,那么将Y重新给0block,此时0block将会把保存的之前的X来与Y相结合构成键值对,然后再用Y去查询,并将结果输出给1block。
具体来说,在堆叠两个解码器块的情况下,第一个块(即 block 0)将接收输入序列并执行自注意力机制、编码器-解码器注意力机制、前馈神经网络等操作,最后将得到输出结果。这个输出结果将成为第二个块(即 block 1)的输入,同时也会作为下一个时间步的编码器-解码器注意力机制中的键和值。如果输出结果不是结束符号,那么第二个块将继续执行自注意力机制、编码器-解码器注意力机制、前馈神经网络等操作,进一步提取特征和生成下一个词元的概率分布。如果输出结果仍然不是结束符号,这个过程将一直持续下去,直到生成到结束符号或达到预定的长度为止。在这个过程中,由于编码器-解码器注意力机制需要将之前的编码器输出作为键和值,所以每次处理时需要保存编码器输出的信息。同时,由于解码器块内部使用残差连接和层归一化来平衡信息流,也需要将之前的输入信息保存下来。具体而言,对于每个解码器块,其输入包括当前时间步的词嵌入向量和编码器-解码器注意力机制的输出作为键值对,而输出则包括下一个词元的概率分布、自注意力机制和编码器-解码器注意力机制的中间结果等信息。这样,就可以将解码器块看作是一个逐步生成文本的过程,每个块负责根据之前的输入和上下文信息,生成下一个词元的概率分布。

9.6模型训练

我们来训练一下模型,依照Transformer架构来实例化编码器-解码器模型。在这里,指定Transformer的编码器和解码器都是2层,都使用4头注意力。为了进行序列到序列的学习,下面在“英语-法语”机器翻译数据集上训练Transformer模型。

num_hiddens, num_layers, dropout, batch_size, num_steps = 32, 2, 0.1, 64, 10
lr, num_epochs, device = 0.005, 200, d2l.try_gpu()
ffn_num_input, ffn_num_hiddens, num_heads = 32, 64, 4
key_size, query_size, value_size = 32, 32, 32
norm_shape = [32]

train_iter, src_vocab, tgt_vocab = d2l.load_data_nmt(batch_size, num_steps)
encoder = TransformerEncoder(
    len(src_vocab), key_size, query_size, value_size, num_hiddens,
    norm_shape, ffn_num_input, ffn_num_hiddens, num_heads,
    num_layers, dropout)
decoder = TransformerDecoder(
    len(tgt_vocab), key_size, query_size, value_size, num_hiddens,
    norm_shape, ffn_num_input, ffn_num_hiddens, num_heads,
    num_layers, dropout)
net = d2l.EncoderDecoder(encoder, decoder)
d2l.train_seq2seq(net, train_iter, lr, num_epochs, tgt_vocab, device)

在这里插入图片描述
拿几个案例测试一下:

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 = d2l.predict_seq2seq(
        net, eng, src_vocab, tgt_vocab, num_steps, device, True)
    print(f'{eng} => {translation}, ',
          f'bleu {d2l.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

和之前的工作一样,我们可视化一下最后一个句子’i’m home .'的翻译热值图,编码器自注意力权重的形状为(编码器层数,注意力头数,num_steps或查询的数目,num_steps或“键-值”对的数目)。

enc_attention_weights = torch.cat(net.encoder.attention_weights, 0).reshape((num_layers, num_heads,
    -1, num_steps))
enc_attention_weights.shape

torch.Size([2, 4, 10, 10])

编码器的自注意力中,查询和键都来自相同的输入序列。因为填充词元是不携带信息的,因此通过指定输入序列的有效长度可以避免查询与使用填充词元的位置计算注意力。接下来,将逐行呈现两层多头注意力的权重。每个注意力头都根据查询、键和值的不同的表示子空间来表示不同的注意力。

d2l.show_heatmaps(
    enc_attention_weights.cpu(), xlabel='Key positions',
    ylabel='Query positions', titles=['Head %d' % i for i in range(1, 5)],
    figsize=(7, 3.5))

在这里插入图片描述
为了可视化解码器的自注意力权重和“编码器-解码器”的注意力权重,我们需要完成更多的数据操作工作。例如用零填充被掩蔽住的注意力权重。值得注意的是,解码器的自注意力权重和“编码器-解码器”的注意力权重都有相同的查询:即以序列开始词元(beginning-of-sequence,BOS)打头,再与后续输出的词元共同组成序列。

print(len(dec_attention_weight_seq))
dec_attention_weights_2d = [head[0].tolist()
                            for step in dec_attention_weight_seq
                            for attn in step for blk in attn for head in blk]
dec_attention_weights_filled = torch.tensor(pd.DataFrame(dec_attention_weights_2d).fillna(0.0).values)
dec_attention_weights = dec_attention_weights_filled.reshape((-1, 2, num_layers, num_heads, num_steps))
dec_self_attention_weights, dec_inter_attention_weights = dec_attention_weights.permute(1, 2, 3, 0, 4)
dec_self_attention_weights.shape, dec_inter_attention_weights.shape

6
(torch.Size([2, 4, 6, 10]), torch.Size([2, 4, 6, 10]))

由于解码器自注意力的自回归属性,查询不会对当前位置之后的“键-值”对进行注意力计算。

#第一个行是自注意力,可以看到没有在一行中生成多个查询,而编码器-解码器则有多个查询
# Plusonetoincludethebeginning-of-sequencetoken
d2l.show_heatmaps(
    dec_self_attention_weights[:, :, :, :len(translation.split()) + 1],
    xlabel='Key positions', ylabel='Query positions',
    titles=['Head %d' % i for i in range(1, 5)], figsize=(7, 3.5))

在这里插入图片描述
与编码器的自注意力的情况类似,通过指定输入序列的有效长度,[输出序列的查询不会与输入序列中填充位置的词元进行注意力计算]。

d2l.show_heatmaps(
    dec_inter_attention_weights, xlabel='Key positions',
    ylabel='Query positions', titles=['Head %d' % i for i in range(1, 5)],
    figsize=(7, 3.5))

在这里插入图片描述
尽管Transformer架构是为了序列到序列的学习而提出的,但Transformer编码器或Transformer解码器通常被单独用于不同的深度学习任务中。(D2L)

9.7小结

  • Transformer是编码器-解码器架构的一个实践,尽管在实际情况中编码器或解码器可以单独使用。
  • 在Transformer中,多头自注意力用于表示输入序列和输出序列,不过解码器必须通过掩蔽机制来保留自回归属性。
  • Transformer中的残差连接和层规范化是训练非常深度模型的重要工具。
  • Transformer模型中基于位置的前馈网络使用同一个多层感知机,作用是对所有序列位置的表示进行转换。

10.作者的话

本文从注意力机制开始,结合D2L相关内容一步一步构建了具有自注意力机制的transformer,在代码层面重要的地方做出了详细的说明,给出了流程分析等,因为篇幅有限,transformer在文本领域的处理还涉及到很多工作,例如预处理机制等等,而本文略过了这些内容来尽可能减少读者的阅读成本开销,行文仓促,文中若有错误,还请诸位读者海涵,不吝赐教,下一阶段的任务是有一篇论文《AN IMAGE IS WORTH 16X16 WORDS:TRANSFORMERS FOR IMAGE RECOGNITION AT SCALE》是基于transformer在图像视觉领域的应用,如果后续本文反响尚可,将会抽时间记录该论文的阅读工作。
最后,感谢您的阅读,感谢《动手学深度学习DIVE INTO DEEP LEARNING》提供的开源线上代码和阅读教程,在此对一切为了开源事业造福人类的工作人员们表示衷心的感谢!本文可随意分享,您的支持将是作者最大的更新动力,分享转载请备注来源,行文仓促,若有错误,还请不吝赐教,谢谢!

作者:寒秋夜未央

Transformer模型简要分析(上篇)(代码来源D2l)
Transformer模型简要分析(中篇)(代码来源D2l)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值