Okii's blog

仅作为记录自己的学习过程

0%

Transformers 理解

本章是Transformers精讲,并配备哈佛版的基于Pytorch的实现代码

一、宏观角度

1、首先将该模型视为一个黑匣子。 在机器翻译应用程序中,它将采用一种语言的句子,并以另一种语言输出其翻译。

image-20240113204413171

2、继续瓦解,可以看到一个编码组件、一个解码组件以及它们之间的连接。

image-20240113204543371

3、编码组件是一堆Encoder(论文将其中六个编码器堆叠在一起 - 6 没有什么神奇之处,绝对可以尝试其他排列)。 解码组件是相同数量的Decoder的堆栈。

image-20240113204719720

4、这些编码器在结构上都是相同的(但它们不共享权重)。 每一层又分为两个子层:

image-20240113204801613Encoder的输入首先流经Self-Attention,该层帮助编码器在对特定单词进行编码时查看输入句子中的其他单词。 Self-Attention将在后续小节详细介绍。

Self-Attention的输出被馈送到前馈神经网络。 完全相同的前馈网络独立应用于每个位置。

5、Decoder具有这两个层,但它们之间是一个注意力层,帮助解码器关注输入句子的相关部分(类似于 seq2seq 模型中注意力的作用)。

image-20240113205106009

6、有了宏观的感受,再来看一下原论文中的图

image-20240113205906204

拆解完再回过来看是不是更清晰,接下来我将逐一介绍其中的各个模块,并配有Pytorch代码实现

二、Self-Attention

2.1 理论部分

其实按照模型流程应该先介绍输入部分

输入部分包括:词向量嵌入 + 位置向量嵌入(对应维度直接相加)

但是,先讲完Self-Attention,你就会明白为什么光有词向量嵌入还不够,还需要位置向量嵌入

正式开始Self-Attention

image-20240113211345136

假设我们已经得到了模型的输入,每个单词都嵌入到大小为 512 的向量中。我将用这些简单的框表示这些向量

将单词嵌入到输入序列中后,每个单词都会流经Encoder的两个子层。

image-20240113211618648

接下来,我将示例切换为较短的句子,去查看编码器的每个子层中发生的情况

正如已经提到的,Encoder接收向量列表作为输入。 它通过将这些向量传递到Self-Attention层,然后传递到前馈神经网络,然后将输出向上发送到下一个Encoder来处理该列表。

image-20240113211916839

Self-Attention即自注意力机制,感动陌生很正常,因为当时正是在这篇论文中提出的

下面我将介绍到底什么叫自注意力机制

假设以下句子是要翻译的输入句子:

The animal didn't cross the street because it was too tired

这句话中的it指的是什么? 指的是街道还是动物? 这对人类来说是一个简单的问题,但对算法来说就不那么简单了。

当模型处理it这个词时,自注意力使其能够将it与“动物”联系起来。

当模型处理每个单词(输入序列中的每个位置)时,自注意力允许它查看输入序列中的其他位置以寻找有助于更好地编码该单词的线索。

你肯定还是不明白它是怎么做的,接下来是细节部分

我们首先看看如何使用向量计算自注意力,然后继续看看它是如何实际实现的——使用矩阵。

计算自注意力的第一步是从每个编码器的输入向量(在本例中为每个单词的嵌入)创建三个向量。 因此,对于每个单词,我们创建一个查询向量、一个键向量和一个值向量。 这些向量是通过将嵌入乘以我们在训练过程中训练的三个矩阵来创建的。

image-20240113212618490

x1 乘以 Wq 权重矩阵会产生 q1,即与该单词关联的“查询”向量。 我们最终为输入句子中的每个单词创建一个“查询”、一个“键”和一个“值”投影。

这里注意一下维度, 在论文中的Embedding维度d_model = 512,给出的Key维度和Value维度均为64

d_k = d_v = d_model / h = 64,那么对应QKV的矩阵WqWkWv大小都应该是(512,64)

这样就能根据输入得到一个查询向量q1,一组键值对<k1,v1>

有了QKV, 接下来需要按照Attention的流程计算q1k1Score

根据论文中提到的缩放点积注意力(Scaled Dot-Product Attention):

image-20240113213524039

先进行点积, 再进行缩放, 计算完q1与句中所有单词的k1,k2……kn的得分(这里采用点积得到)后, 再对Score除以根号下d_k, 完成缩放, 最后再通过Softmax得到Attention权重, 加权求和结果称为z1

image-20240113213705835

上面的讨论全部都是针对一个单词的, 但是在实际的运算中, 由于Encoder是线性Stack起来的, 所以其实Encoder的训练是可以并行的, 即多个单词做完Embedding后作为一个矩阵并行计算, 假设输入矩阵X,通过WqWkWv计算后可以得到Q、K、V

image-20240114201121264

最后,由于我们处理的是矩阵,我们可以将上述步骤压缩为一个公式来计算自注意力层的输出:

​ $$ Attention\left( Q,K,V \right) \ =\ Soft\max \left( \frac{QK^T}{\sqrt[]{d_k}} \right) V $$

image-20240114201259276

这里除以根号下 d_k 的解释

d_k非常大时, 求得的内积可能会非常大, 如果不进行缩放, 不同的内积大小可能差异会非常大, Softmax在指数运算可能将梯度推到特别小, 导致梯度消失

d_k较大时,很有可能存在某个 key,其与query计算出来的对齐分数远大于其他的key与该 query算出的对齐分数。这时,softmax 函数对另外的qk偏导数都趋于 0.

这样结果就是,softmax函数梯度过低(趋于零),使得模型误差反向传播经过softmax 函数后无法继续传播到模型前面部分的参数上,造成这些参数无法得到更新,最终影响模型的训练效率

2.2 代码实现

Pytorch代码实现如下

1
2
3
4
5
6
7
8
9
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
  • querykey,和 value 的维度是 (batch_size, seq_len, d_model),其中 seq_len 是输入序列的长度,d_model 是模型的隐藏单元数

  • 在线性映射的步骤中,l(x).view(nbatches, -1, self.h, self.d_k).transpose(1, 2) 使用了多头 (self.h) 和 d_k,它将 querykey,和 value 映射到了 (batch_size, h, seq_len, d_k) 的维度

  • torch.matmul(query, key.transpose(-2, -1)) 计算注意力分数,其中 querykey 经过了 transpose 操作以匹配维度,最终得到的分数维度是 (batch_size, h, seq_len, seq_len),在最后两个维度的行上做了softmax

  • mask 步骤中,如果有掩码,就使用 scores.masked_fill(mask == 0, -1e9) 将不应考虑的位置的分数设置为一个极小的值 -1e9

  • 通过 F.softmax(scores, dim=-1) 对分数进行softmax 操作,得到注意力权重 p_attn,其维度为 (batch_size, h, seq_len, seq_len)

  • F.softmax(scores, dim=-1)如果是一个二维的张量,dim=0表示在列上做softmaxdim=1表示在行上做softmax

  • 最后通过 torch.matmul(p_attn, value) 得到经过注意力权重调节后的值,其维度为 (batch_size, h, seq_len, d_k)

    batch_size 是每个 batch 的大小,seq_len 是序列的长度,h 是头数,d_k 是每个头的隐藏单元数。在多头注意力机制中,通过对头数进行拼接,最后的输出维度为 (batch_size, seq_len, h * d_k)

    2.3 多头注意力机制

    多头的思路和CNN中的多个卷积核起到的作用明显是一致的. 所谓”多头”, 放在卷积神经网络里就是卷积层多个卷积核的特征提取过程, 在这里就是进行多次注意力的提取, 就像多个卷积核一样, 多次不同的初始化矩阵经过训练可能会有多种不同的特征, 每个头用于将输入嵌入投影到不同的表示子空间中,更有利于不同角度的特征抽取和信息提取。

    image-20240116133617842

    通过多头注意力,为每个头维护单独的 Q/K/V 权重矩阵,从而产生不同的 Q/K/V 矩阵。 正如我们之前所做的那样,我们将 X 乘以 WQ/WK/WV 矩阵以生成 Q/K/V 矩阵。

    image-20240116133811713

    如果进行与上面概述相同的自注意力计算,只是使用不同的权重矩阵进行八次不同的计算,我们最终会得到八个不同的 Z 矩阵

    但是接下来的前馈层不需要八个矩阵——它需要一个矩阵(每个单词一个向量)

    所以我们需要一种方法将这八个压缩成一个矩阵

    该怎么做呢? 将矩阵连接起来,然后将它们乘以一个附加的权重矩阵 Wo

    image-20240116134104328这几乎就是多头自注意力的全部内容。 这是相当多的矩阵。

    用下图进行汇总

    image-20240116134218698

    Pytorch代码实现如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    class MultiHeadedAttention(nn.Module):
    def __init__(self, h, d_model, dropout=0.1):
    super(MultiHeadedAttention, self).__init__()
    # 判断d - model % h是否有余数,如果有就报错
    assert d_model % h == 0
    # d-model一般为512(序列符号的embedding长度),h是头数一般为8
    self.h = h
    # 两者相除得到d_k的长度,即query、key矩阵中的列数
    self.d_k = d_model // h
    # 这里定义的4个线性层, 相当于Wq、Wk、Wv、Wo四个投影矩阵
    self.linears = clones(nn.Linear(d_model, d_model), 4)
    self.attn = None
    self.dropout = nn.Dropout(p=dropout)

    def forward(self, query, key, value, mask=None):
    if mask is not None:
    # Same mask applied to all h heads.
    mask = mask.unsqueeze(1)
    nbatches = query.size(0)

    # 1) Do all the linear projections in batch from d_model => h x d_k
    # zip中的(query, key, value)相当于原始的输入, 即 词嵌入 + 位置嵌入
    # l(x) 相当于原始嵌入输入过了Wq、Wk、Wv三个映射矩阵得到query, key, value
    # 然后再reshape到 nbatches * head * seq_len * d_k(64)
    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))]
    # 2) Apply attention on all the projected vectors in batch.
    x, self.attn = attention(query, key, value, mask=mask, dropout=self.dropout)

    # 3) "Concat" using a view and apply a final linear.
    x = x.transpose(1, 2).contiguous().view(nbatches, -1, self.h * self.d_k)
    return self.linears[-1](x)

三、Positional Encoding

3.1 理论部分

正式因为Transformer采用了纯粹的Attention结构, 不像RNN一样能够通过时间步来反映句子中单词的前后关系, 即不能得知位置信息。要知道, 在NLP任务中, 语序是一个相当重要的属性, 所以必须要通过某种方式让Transformer得知单词的位置, 作者通过位置编码在每次进入EncoderDecoder前将位置信息写入。这样来看, 与其叫位置编码, 不如叫位置嵌入

位置编码可以直接与Embedding的向量相加:

image-20240114162517162

那这个位置编码是怎么得到的呢?

作者的做法非常有意思, 对不同的单词位置, 不同的Embedding维度, 它的编码都是唯一的, 应用正弦和余弦函数也方便Transformer学到位置的特征. 如果将当前单词位置记为pos, 而词向量的某个维度记为i, 那么位置编码的方法为:

image-20240114162811559

如果我们假设嵌入的维数为4,则实际的位置编码将如下所示:

image-20240114162837902

根据上述PE的位置编码公式,在这个式子中, 编码周期不受单词位置影响, 仅仅与模型开始设计的d_model
Embedding的不同维度i相关

对于不同的i,根据三角函数的周期公式T = 2Π / wi的范围是[0,256]

可以得到PE的的周期变化范围是[2Π,10000 * 2Π ]

这样看,同一位置上的词语, 对于不同的Embedding维度, 都得到不同的编码, 并且随着i的增大, 位置编码的值的变化就越来越慢. 这种编码对于不同维度的Embedding来说是唯一的, 因此模型能够学习到关于Embedding的位置信息。

为什么会选择如上公式呢?作者表示:

image-20240114195410034

已知三角函数公式如下:

image-20240114195423850

偏移k后,得到的PE如下:

image-20240114195522326

作者希望借助上述绝对位置的编码公式,让模型能够学习到相对位置信息

3.2 代码实现

Pytorch代码实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Embeddings(nn.Module):
def __init__(self, d_model, vocab):
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=5000):
super(PositionalEncoding, self).__init__()
self.dropout = nn.Dropout(p=dropout)
# pe (5000 * 512)
pe = torch.zeros(max_len, d_model)
# position (5000 * 1)
position = torch.arange(0, max_len).unsqueeze(1)
# div_term (256 * 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)
# [1, max_len, d_model]
# pe (1 * 5000 * 512)
pe = pe.unsqueeze(0)
self.register_buffer('pe', pe)

def forward(self, x):
# 输入的最终编码 = word_embedding + positional_embedding
x = x + self.pe[:, :x.size(1)].detach()
return self.dropout(x)

四、Residuals + LN + FFN

在继续之前,需要提及Encoder架构中的一个细节,即每个编码器中的每个子层(自注意力,ffnn)周围都有一个残差连接,并且后面是层归一化步骤。

4.1 残差连接

image-20240116135601496

如果要可视化与自注意力相关的向量和层归一化操作,如下图:

image-20240116135615871

这也适用于Decoder的子层。 如果我们考虑一个由 2 个堆叠编码器和解码器组成的 Transformer,它看起来会是这样的:

image-20240116135752823

4.2 层归一化

Layer Norm也是一种类似于Batch Norm的归一化方式, 同样能起到加快收敛的作用, 在NLP任务中比较常用

Batch Norm中, 记录下一个Batch中每维Feature的均值和方差, 并进行放缩和平移, 即对不同样本的同一个通道特征进行归一化

Layer Norm中, 只是换了一个维度, 我们对同一个样本的不同通道进行归一化

img

BN和LN的区别:

主要区别在于 normalization的方向不同!

Batch顾名思义是对一个batch进行操作。假设我们有 10行 3列 的数据,即我们的batchsize = 10,每一行数据有三个特征,假设这三个特征是**[身高、体重、年龄]。那么BN是针对每一列(特征)进行缩放,例如算出[身高]**的均值与方差,再对身高这一列的10个数据进行缩放。体重和年龄同理。这是一种“列缩放”。

layer方向相反,它针对的是每一行进行缩放。即只看一笔数据,算出这笔所有特征的均值与方差再缩放。这是一种“行缩放”。

细心的你已经看出来,layer normalization对所有的特征进行缩放,这显得很没道理。我们算出一行这**[身高、体重、年龄]**三个特征的均值方差并对其进行缩放,事实上会因为特征的量纲不同而产生很大的影响。但是BN则没有这个影响,因为BN是对一列进行缩放,一列的量纲单位都是相同的。

那么我们为什么还要使用LN呢?因为NLP领域中,LN更为合适。

如果我们将一批文本组成一个batch,那么BN的操作方向是,对每句话的第一个词进行操作。但语言文本的复杂性是很高的,任何一个词都有可能放在初始位置,且词序可能并不影响我们对句子的理解。而BN针对每个位置进行缩放,这不符合NLP的规律

LN则是针对一句话进行缩放的,且LN一般用在第三维度,如[batchsize, seq_len, dims]中的dims,一般为词向量的维度,或者是RNN的输出维度等等,这一维度各个特征的量纲应该相同。因此也不会遇到上面因为特征的量纲不同而导致的缩放问题。

image-20240116150026767

BN 感觉是对样本内部特征的缩放,LN 是样本直接之间所有特征的缩放。为啥BN不适合NLP 是因为NLP模型训练里的每次输入的句子都是多个句子,并且长度不一,那么 针对每一句的缩放才更加合理,才能表达每个句子之间代表不同的语义表示,这样让模型更加能捕捉句子之间的上下语义关系。如果要用BN,它首先要面临的长度不一的问题。有时候batch size越小的bn效果更不好

4.3 代码实现

Pytorch代码实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class LayerNorm(nn.Module):
def __init__(self, features, eps=1e-6):
super(LayerNorm, self).__init__()
# 层归一化后, 引入这两个可学习参数
# Layer Normalization 能够更灵活地适应不同的数据分布
# 练过程中,这两个参数会通过反向传播进行更新,以最优化模型的性能
self.a_2 = nn.Parameter(torch.ones(features))
self.b_2 = nn.Parameter(torch.zeros(features))
self.eps = eps

def forward(self, x):
# mean, std的计算在最后一个维度(emb_dim)上进行的
mean = x.mean(-1, keepdim=True)
std = x.std(-1, keepdim=True)
# 对 x - mean 和 (std + self.eps) 的运算仍然是逐元素的,维度与输入张量 x 一致
# 最终返回的 self.a_2 * (x - mean) / (std + self.eps) + self.b_2
# 的形状为(batch_size, seq_len, emb_dim)
return self.a_2 * (x - mean) / (std + self.eps) + self.b_2

# Encoder输出和最后进入FFN之前的Residual+LN
class SublayerConnection(nn.Module):

def __init__(self, size, dropout):
super(SublayerConnection, self).__init__()
self.norm = LayerNorm(size)
self.dropout = nn.Dropout(dropout)

def forward(self, x, sublayer):
return x + self.dropout(sublayer(self.norm(x)))


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))))

五、Decoder

5.1 理论部分

现在我们已经涵盖了Encoder方面的大部分概念,我们基本上也知道了解码器的组件是如何工作的

但让我们看看它们是如何协同工作的

Encoder首先处理输入序列。 然后,顶部Decoder的输出被转换为一组注意力向量K V。这些向量将由每个Decoder在其Encoder-Decoder Attention层中使用,这有助于Decoder关注输入序列中的适当位置:

img

以下步骤重复该过程,直到到达特殊符号,指示Decoder已完成其输出。 每个步骤的输出在下一个时间步骤中被馈送到底部Decoder,并且Decoder像编码器一样冒泡其解码结果

img

我们将位置编码嵌入并添加到这些Decoder输入中以指示每个单词的位置,这和处理Encoder的输入时一样

但是我们要知道Decoder中的Attention层的运行方式与Encoder中的运行方式略有不同:

Decoder中,Attention层只允许关注输出序列中较早的位置。 这是通过在自注意力计算中的 softmax 步骤之前屏蔽未来位置(将它们设置为 -inf)来完成的,因为在推理的时候肯定不能看到后面的结果

“Encoder-Decoder Attention”层的工作方式与多头自注意力类似,只不过它从其下面的层创建查询矩阵,并从Encoder最后一层的输出中获取键和值矩阵

若仍然沿用传统Seq2Seq+RNN的思路, Decoder是一个顺序操作的结构, 我们代入一个场景来看看。假设我们要执行机器翻译任务, 要将我 是 学生翻译为I am Student, 假设所有参数与论文中提到的参数一样, batch size视为1. 根据前面已知的知识, Encoder堆叠后的输入和Embedding的大小是相同的

在这里有三个词语, Embedding且通过Encoder后的编码大小为(3,512)。下面对Decoder进行训练:

  1. 将起始符<start> 作为初始Decoder输入, 经过Decoder处理和分类得到输出I.
  2. <start> I作为Decoder输入, 经过Decoder处理和分类得到输出am.
  3. <start> I am作为Decoder输入, 经过Decoder处理和分类得到输出Student.
  4. <start> I am student作为Decoder输入, 经过Decoder处理和分类得到结束符<end>.

这种预测的方式也称为自回归

如果想做到并行训练, 需要将上面的过程转化为一个这样的矩阵直接作为Decoder的输入

因为在训练时已知任务标签, 所以可以产生类似的效果,这种方法被称为Teacher Forcing

在论文的图中, Mask操作顺序被放在QK计算并缩放后, Softmax计算前。如果继续计算下去, 不做Mask, 与V相乘后得到Attention, 所有时间步信息全部都被泄露给Decoder, 必须用Mask将当前预测的单词信息和之后的单词信息全部遮住。

遮住的方法非常简单, 首先不能使用0进行遮盖, 因为Softmax中用零填充会产生错误, e^0=1. 所以必须要用−∞来填充那些不能被看见的部分. 我们直接生成一个下三角全为0, 上三角全部为负无穷的矩阵, 与原数据相加就能完成遮盖的效果

img

Softmax时, 所有的负无穷全变成了0, 不再干扰计算:

img

其实Mask在对句子的无效部分填充时, 也是用同样方法将所有句子补齐, 无效部分用负无穷填充的

PS:Decoder仍然依赖与先前输出结果作为输入, 所以在正式使用时不能实现并行预测, 但在训练的时结果是已知的, 可以实现并行训练

5.2 代码实现

Pytorch代码实现如下

1
2
3
4
5
6
7
8
9
10
11
12
class Decoder(nn.Module):
"Generic N layer decoder with masking."

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)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class DecoderLayer(nn.Module):
"Decoder is made of self-attn, src-attn, and feed forward (defined below)"

def __init__(self, size, self_attn, src_attn, feed_forward, dropout):
super(DecoderLayer, self).__init__()
self.size = size
self.self_attn = self_attn
self.src_attn = src_attn
self.feed_forward = feed_forward
self.sublayer = clones(SublayerConnection(size, dropout), 3)

def forward(self, x, memory, src_mask, tgt_mask):
"Follow Figure 1 (right) for connections."
m = memory
# 第一个attention是自注意力,Q,K,V 都是 x
# 第二个attention的Q是上一层自注意力汇聚输出的x,K和V都是encoder编码的memory
x = 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)

六、The Final Linear and Softmax Layer

Decoder堆栈输出浮点数向量。 我们如何把它变成一个词?

这就是最后一个Linear层的工作,后面是 Softmax

线性层是一个简单的全连接神经网络,它将Decoder堆栈产生的向量投影到一个更大的向量中,称为logits向量

假设我们的模型知道从训练数据集中学习的 10000 个独特的英语单词(我们模型的“输出词汇”)

这将使 logits 向量有 10000 个单元格宽——每个单元格对应一个唯一单词的分数。 这就是我们解释线性层模型输出的方式。

然后,Softmax 层将这些分数转换为概率(全部为正,全部加起来为 1.0)。 选择概率最高的单元格,并生成与其关联的单词作为该时间步的输出。

image-20240117145150356