深入解析随机 Transformer [译]
深入浅出地探索 Transformer 背后的数学原理,了解其工作原理
在本篇博客文章中,我们将详细展示一个 Transformer 模型在数学上的端对端(end-to-end)实例。我们的目标是彻底理解模型是如何运作的。为了让这个过程更加易于操作,我们将对模型进行大量简化。考虑到我们需要亲手进行不少数学计算,我们会减少模型的维度。比如说,我们不会使用 512 维的嵌入(embeddings),而是选用 4 维的嵌入。这样做可以让数学部分更容易理解!我们会使用随机生成的向量和矩阵,但你也可以用自己的数值来跟随实例。
正如你会看到的,这些数学运算其实并不复杂。复杂之处在于处理的步骤和参数的数量。在阅读这篇文章之前,我建议你先阅读或同时参考 图解 Transformer 这篇博客,这是一篇用非常直观且形象的方式介绍 Transformer 模型的优秀文章,我在这里不打算重复它已经讲解过的内容。我的目标是解释 Transformer 模型的“如何工作”,而不是“它是什么”。如果你想更深入地了解,不妨查看那篇著名的原论文:Attention is all you need。
先决条件
这里需要一些基础的线性代数知识 - 我们主要进行的是简单的矩阵乘法,所以无需深入专业。此外,对机器学习和深度学习有基本了解将会很有帮助。
这篇文章包括了什么?
- 在推理过程中,一个 Transformer 模型的数学端对端实例
- 对注意力机制(attention mechanisms)的解释
- 对残差连接(residual connections)和层标准化(layer normalization)的解释
- 一些用于扩展模型的代码!
话不多说,让我们开始吧!原始的 Transformer 模型包含两个部分:编码器(encoder)和解码器(decoder)。我们的目标是将这个模型作为翻译工具来使用!首先,我们将关注编码器部分。
编码器
编码器的主要目标是生成输入文本的丰富的嵌入(embedding)表示。这种嵌入能够捕获关于输入的语义信息,随后将其传递给解码器,以产生输出文本。编码器由多个 N 层堆叠而成。在我们详细探讨这些层之前,我们需要先了解如何将单词(或 Token)输入到模型中。
注释
嵌入是一个经常被提及的术语。我们首先会创建一个嵌入,这将作为编码器的输入。编码器也会输出一个嵌入(有时也被称为隐藏状态)。解码器也将接收一个嵌入!😅 嵌入的核心作用是将一个 Token 转化为一个向量。
1. 文本嵌入
比如我们想将“Hello World”从英文翻译成西班牙语。第一步是使用嵌入算法将每个输入 Token 转换为一个向量。这是一种经过学习的编码方式。通常我们会使用较大的向量尺寸,如 512,但为了简化我们的例子,我们这里使用尺寸为 4 的向量。
Hello -> [1,2,3,4] World -> [2,3,4,5]
这样我们就能将输入表示为一个矩阵
注释
虽然我们可以把这两个嵌入作为独立的向量来处理,但把它们作为一个单独的矩阵来管理会更加简单。这是因为在我们后续的处理中,我们将要进行矩阵乘法!
2 位置编码
现有的嵌入技术无法反映出单词在句子中的具体位置,因此,我们需要引入额外的位置信息。为此,我们选择将位置编码添加到嵌入中。关于位置编码的生成,有多种方法可选,包括学习式嵌入和固定向量。根据原论文的研究(详见原文第 3.5 节),两者的效果几乎相同,因此我们这里也采用固定向量。正弦和余弦函数以其波动的特征,随时间周期性变化。利用这一点,我们可以为句中的每个位置赋予一组既独特又一致的数字模式。这正是论文中所采用的方法(见 3.5 节):
具体做法是,在嵌入的每个值中,用正弦和余弦函数进行插值处理(偶数索引使用正弦,奇数索引使用余弦)。以“Hello”为例,来计算一下吧!
- i = 0(偶数): 位置编码 PE(0,0) = sin(0 / 10000^(0 / 4)) = sin(0) = 0
- i = 1(奇数): 位置编码 PE(0,1) = cos(0 / 10000^(2*1 / 4)) = cos(0) = 1
- i = 2(偶数): 位置编码 PE(0,2) = sin(0 / 10000^(2*2 / 4)) = sin(0) = 0
- i = 3(奇数): 位置编码 PE(0,3) = cos(0 / 10000^(2*3 / 4)) = cos(0) = 1
对于“World”
- 当 i = 0(偶数)时:PE(1,0) 计算为 sin(1 / 10000^(0 / 4)),即 sin(1 / 10000^0),结果是 sin(1),大约为 0.84。
- 当 i = 1(奇数)时:PE(1,1) 计算为 cos(1 / 10000^(2*1 / 4)),即 cos(1 / 10000^0.5),约等于 cos(0.01),大约为 0.99。
- 当 i = 2(偶数)时:PE(1,2) 计算为 sin(1 / 10000^(2*2 / 4)),即 sin(1 / 10000^1),大约为 0。
- 当 i = 3(奇数)时:PE(1,3) 计算为 cos(1 / 10000^(2*3 / 4)),即 cos(1 / 10000^1.5),大约为 1。
从上述计算可以看出:
- 单词“Hello”的编码转换为 [0, 1, 0, 1]。
- 单词“World”的编码转换为 [0.84, 0.99, 0, 1]。
请注意,这些编码的维度与原始嵌入的维度是一致的。
3. 添加位置编码及其嵌入过程
我们接下来将进行位置编码的添加操作,具体是将位置编码与原始嵌入向量进行相加合并。
例如,“Hello” = [1,2,3,4] 加上位置编码 [0, 1, 0, 1],结果为 [1, 3, 3, 5];“World” = [2,3,4,5] 加上位置编码 [0.84, 0.99, 0, 1],结果为 [2.84, 3.99, 4, 6]。
因此,我们得到的新矩阵,即将输入到编码器中的矩阵,如下所示:
如果您参考原始论文中的图示,我们所做的正是图中左下部分的内容(嵌入加上位置编码)。
原文中的“注意力就是你所需要的”论文里的 Transformer 模型
4. 自我关注机制
4.1 矩阵定义
接下来,我们将介绍多头关注机制的概念。所谓关注机制,是指模型能够专注于输入数据的特定部分的一种方式。多头关注机制则是模型同时关注来自不同表示子空间的信息的一种方法,这通过使用多个关注头实现。每个关注头都有自己的 K、V 和 Q 矩阵。
以我们的示例来说,我们使用两个关注头。这里,我们将为这些矩阵赋予随机值。每个矩阵都是一个 4x3 的矩阵。通过这种方式,每个矩阵将 4 维的嵌入向量转换为 3 维的键、值和查询向量。这样做可以减少关注机制需要处理的维度,有助于控制计算复杂性。需要注意的是,如果关注力的维度设置得太小,可能会影响模型的性能。我们这里就随机选取以下数值:
对于第一个关注头
对于第二个关注头
4.2 计算键、查询和值
要获得键(Keys)、查询(Queries)和值(Values),我们需要将输入嵌入乘以权重矩阵。
计算键
实际上,我不打算亲手完成所有这些重复的数学计算,这会使网页布局混乱。那就让我们借助 NumPy 来完成这些计算吧。
首先,我们定义这些矩阵:
import numpy as npWK1 = np.array([[1, 0, 1], [0, 1, 0], [1, 0, 1], [0, 1, 0]])WV1 = np.array([[0, 1, 1], [1, 0, 0], [1, 0, 1], [0, 1, 0]])WQ1 = np.array([[0, 0, 0], [1, 1, 0], [0, 0, 1], [1, 0, 0]])WK2 = np.array([[0, 1, 1], [1, 0, 1], [1, 1, 0], [0, 1, 0]])WV2 = np.array([[1, 0, 0], [0, 1, 1], [0, 0, 1], [1, 0, 0]])WQ2 = np.array([[1, 0, 1], [0, 1, 0], [1, 0, 0], [0, 1, 1]])
然后,我们确认一下以上计算是否正确。
embedding = np.array([[1, 3, 3, 5], [2.84, 3.99, 4, 6]])K1 = embedding @ WK1K1
array([[4. , 8. , 4. ],[6.84, 9.99, 6.84]])
太好了!接下来,我们来看看值和查询的计算。
计算值
V1 = embedding @ WV1V1
array([[6. , 6. , 4. ],[7.99, 8.84, 6.84]])
计算查询
Q1 = embedding @ WQ1Q1
array([[8. , 3. , 3. ],[9.99, 3.99, 4. ]])
我们暂时不考虑第二个头部,先关注第一个头部的最终得分。我们稍后再回到第二个头部。
4.3 注意力得分的计算
计算注意力得分包括以下几个步骤:
- 计算查询与各个键的点积
- 将结果除以键向量的维度平方根
- 应用 softmax 函数,得到注意力权重
- 将每个值向量与注意力权重相乘
4.3.1 点积运算:查询向量与各键向量的计算
要计算“Hello”的得分,我们需要计算查询向量 q1 与每个键向量(k1 和 k2)的点积。
在矩阵运算中,相当于将 Q1 与 K1 的转置相乘。
为了确保无误,我们不妨用 Python 再次验证一下。
scores1 = Q1 @ K1.Tscores1
array([[ 68. , 105.21 ],[ 87.88 , 135.5517]])
4.3.2 对得分进行调整
我们接下来会对得分做一些调整:把它们除以 key 向量维度的平方根(本例中为 3,原论文中则为 64)。为什么这么做呢?原因是当 key 的维度(d)很大时,点积(即多个数相乘再相加的结果)可能会变得非常大。而过大的值在这里并不理想。我们将很快详细讨论这个问题。
scores1 = scores1 / np.sqrt(3)scores1
array([[39.2598183 , 60.74302182],[50.73754166, 78.26081048]])
4.3.3 使用 softmax 函数
然后,我们用 softmax 函数来对得分进行归一化处理,确保所有的得分都是正数,并且它们的总和为 1。
那么,什么是 softmax 函数呢?
简单来说,softmax 函数可以将一组数值转换成一组和为 1 的 0 到 1 之间的数值,这非常适合用来表示概率。它的计算方式如下:
不要被公式吓到了 - 它实际上非常简单。假设我们有以下向量:
应用 softmax 函数后,每个元素都会被转换为一种概率形式。
例如,对于这个向量,经过 softmax 转换后的结果是 ([0.09, 0.24, 0.67]),可以看到这些数值都在 0 到 1 之间,且总和为 1。
def softmax(x):return np.exp(x) / np.sum(np.exp(x), axis=1, keepdims=True)scores1 = softmax(scores1)scores1
array([[4.67695573e-10, 1.00000000e+00],[1.11377182e-12, 1.00000000e+00]])
4.3.4 乘以值矩阵的注意力权值
我们接下来将值矩阵乘以注意力权值。
attention1 = scores1 @ V1attention1
array([[7.99, 8.84, 6.84],[7.99, 8.84, 6.84]])
让我们把 4.3.1、4.3.2、4.3.3 和 4.3.4 的内容合并,形成一个单一的矩阵公式(这个公式来自原始论文的第 3.2.1 节):
就是这样!我们刚才进行的所有数学计算都可以简洁地包含在上述的注意力公式中!现在我们来将这个公式转换成代码!
def attention(x, WQ, WK, WV):K = x @ WKV = x @ WVQ = x @ WQscores = Q @ K.Tscores = scores / np.sqrt(3)scores = softmax(scores)scores = scores @ Vreturn scores
attention(embedding, WQ1, WK1, WV1)
array([[7.99, 8.84, 6.84],[7.99, 8.84, 6.84]])
我们验证了上述过程得到的值是一致的。现在我们可以庆祝一下,并用这个方法来计算第二个注意力头的注意力得分:
attention2 = attention(embedding, WQ2, WK2, WV2)attention2
array([[8.84, 3.99, 7.99],[8.84, 3.99, 7.99]])
如果你好奇为什么两个嵌入的注意力得分相同,那是因为 softmax 函数把我们的得分转换成了 0 和 1。看这里:
softmax(((embedding @ WQ2) @ (embedding @ WK2).T) / np.sqrt(3))
array([[1.10613872e-14, 1.00000000e+00],[4.95934510e-20, 1.00000000e+00]])
这种情况是由于矩阵初始化不当和向量尺寸过小导致的。softmax 应用前的分数差异较大,通过 softmax 处理后会被极大地放大,从而使得某些值接近 1 而其他值接近 0。实际上,我们最初的嵌入矩阵的值可能设定得太高,导致键、值和查询的计算结果过大,这在相乘过程中进一步增加。
还记得我们之前为何要用键的维度的平方根来进行除法吗?这就是原因。如果我们不这样做,点积的结果会太大,经过 softmax 后得到的数值也会过大。在这个例子中,尽管我们的数值很小,但看来这样做还不够!作为一个临时的解决方法,我们可以将数值缩小,比用 3 的平方根缩小的幅度还要大。我们重新定义一下注意力函数,这次将数值缩小 30 倍。虽然这不是一个长期的解决方案,但它能帮助我们得到不同的注意力得分。我们稍后会探索更好的解决方法。
def attention(x, WQ, WK, WV):K = x @ WKV = x @ WVQ = x @ WQscores = Q @ K.Tscores = scores / 30 # we just changed thisscores = softmax(scores)scores = scores @ Vreturn scores
attention1 = attention(embedding, WQ1, WK1, WV1)attention1
array([[7.54348784, 8.20276657, 6.20276657],[7.65266185, 8.35857269, 6.35857269]])
attention2 = attention(embedding, WQ2, WK2, WV2)attention2
array([[8.45589591, 3.85610456, 7.72085664],[8.63740591, 3.91937741, 7.84804146]])
4.3.5 各注意力头的输出结果
在编码器的结构中,下一层仅需接收一个综合矩阵,而非两个单独的矩阵。首先,我们将两个注意力头的输出结果合并起来,这一过程在原论文的第 3.2.2 节有详细描述。
attentions = np.concatenate([attention1, attention2], axis=1)attentions
array([[7.54348784, 8.20276657, 6.20276657, 8.45589591, 3.85610456,7.72085664],[7.65266185, 8.35857269, 6.35857269, 8.63740591, 3.91937741,7.84804146]])
接下来,我们会把合并后的矩阵与一个学习得来的权重矩阵相乘,以此得到注意力层的最终输出。这个权重矩阵的维度设置确保输出结果的维度与原始嵌入的维度一致(在本例中为 4)。
# Just some random valuesW = np.array([[0.79445237, 0.1081456, 0.27411536, 0.78394531],[0.29081936, -0.36187258, -0.32312791, -0.48530339],[-0.36702934, -0.76471963, -0.88058366, -1.73713022],[-0.02305587, -0.64315981, -0.68306653, -1.25393866],[0.29077448, -0.04121674, 0.01509932, 0.13149906],[0.57451867, -0.08895355, 0.02190485, 0.24535932],])Z = attentions @ WZ
array([[ 11.46394285, -13.18016471, -11.59340253, -17.04387829],[ 11.62608573, -13.47454936, -11.87126395, -17.4926367 ]])
The Ilustrated Transformer 上的一幅图像生动地展示了以上全部过程。
5. 前向馈层
5.1 基础前馈层
在自注意力层之后,编码层包含一个前馈神经网络(FFN,Front-Feed Neural Network)。这是一个包含两个线性变换和一个中间的 ReLU(Rectified Linear Unit,修正线性单元)激活函数的简单网络。《插图式 Transformer》的博客文章没有详细介绍它,所以我在这里简单说明一下。FFN 的目的是处理和转换注意力机制生成的数据表示。这个过程通常包括以下几个步骤(参考原论文的 3.3 节):
-
第一线性层: 这个层通常会扩大输入的维度。比如,如果输入维度是 512,那么输出维度可能会增加到 2048。这样做能让模型学习到更复杂的功能。例如,在我们的简单示例中,维度是 4,我们将扩展到 8。
-
ReLU 激活: 这是一种非线性激活函数。它很简单,如果输入是负数就返回 0,正数就返回输入本身。这让模型能够学习非线性功能。其数学表示为:
-
第二线性层: 这个层与第一线性层相反,它会将维度减小回原始大小。在我们的例子中,维度将从 8 减少到 4。
这整个过程可以表示为:
提醒一下,这一层的输入是我们在自注意力层中计算的 Z 值。下面是一些参考值:
现在,我们来为权重矩阵和偏置向量定义一些随机值。你可以用代码来实现,也可以手工计算,如果你愿意的话!
W1 = np.random.randn(4, 8)W2 = np.random.randn(8, 4)b1 = np.random.randn(8)b2 = np.random.randn(4)
现在,我们来编写前向传播函数的代码
def relu(x):return np.maximum(0, x)def feed_forward(Z, W1, b1, W2, b2):return relu(Z.dot(W1) + b1).dot(W2) + b2
output_encoder = feed_forward(Z, W1, b1, W2, b2)output_encoder
array([[ -3.24115016, -9.7901049 , -29.42555675, -19.93135286],[ -3.40199463, -9.87245924, -30.05715408, -20.05271018]])
5.2 全面整合:随机编码器
接下来,我们将展示如何编写代码,把多头注意力机制和前馈网络整合到一个编码器模块里。
请注意
这段代码主要是为了更好地理解和教学目的而设计的,并不是为了追求最高性能!所以请不要过分挑剔!
d_embedding = 4d_key = d_value = d_query = 3d_feed_forward = 8n_attention_heads = 2def attention(x, WQ, WK, WV):K = x @ WKV = x @ WVQ = x @ WQscores = Q @ K.Tscores = scores / np.sqrt(d_key)scores = softmax(scores)scores = scores @ Vreturn scoresdef multi_head_attention(x, WQs, WKs, WVs):attentions = np.concatenate([attention(x, WQ, WK, WV) for WQ, WK, WV in zip(WQs, WKs, WVs)], axis=1)W = np.random.randn(n_attention_heads * d_value, d_embedding)return attentions @ Wdef feed_forward(Z, W1, b1, W2, b2):return relu(Z.dot(W1) + b1).dot(W2) + b2def encoder_block(x, WQs, WKs, WVs, W1, b1, W2, b2):Z = multi_head_attention(x, WQs, WKs, WVs)Z = feed_forward(Z, W1, b1, W2, b2)return Zdef random_encoder_block(x):WQs = [np.random.randn(d_embedding, d_query) for _ in range(n_attention_heads)]WKs = [np.random.randn(d_embedding, d_key) for _ in range(n_attention_heads)]WVs = [np.random.randn(d_embedding, d_value) for _ in range(n_attention_heads)]W1 = np.random.randn(d_embedding, d_feed_forward)b1 = np.random.randn(d_feed_forward)W2 = np.random.randn(d_feed_forward, d_embedding)b2 = np.random.randn(d_embedding)return encoder_block(x, WQs, WKs, WVs, W1, b1, W2, b2)
回想一下,我们的输入是包含位置编码和嵌入的矩阵 E。
embedding
array([[1. , 3. , 3. , 5. ],[2.84, 3.99, 4. , 6. ]])
接下来,我们把这个输入传递给我们的 random_encoder_block
函数
random_encoder_block(embedding)
array([[ -71.76537515, -131.43316885, 13.2938131 , -4.26831998],[ -72.04253781, -131.84091347, 13.3385937 , -4.32872015]])
很好!这只是单一的一个编码器模块。在原始论文中,使用了六个这样的编码器。每个编码器的输出都会成为下一个编码器的输入,以此类推:
def encoder(x, n=6):for _ in range(n):x = random_encoder_block(x)return xencoder(embedding)
/tmp/ipykernel_11906/1045810361.py:2: RuntimeWarning: overflow encountered in expreturn np.exp(x)/np.sum(np.exp(x),axis=1, keepdims=True)/tmp/ipykernel_11906/1045810361.py:2: RuntimeWarning: invalid value encountered in dividereturn np.exp(x)/np.sum(np.exp(x),axis=1, keepdims=True)
array([[nan, nan, nan, nan],[nan, nan, nan, nan]])
5.3 残差和层归一化
哎呀!我们遇到了“NaN”(非数值)的问题!看来我们的数据值太高,传递到下一个编码器时,数值过大导致“爆炸”了!这就是所谓的梯度爆炸 (gradient explosion)。在没有任何归一化的情况下,早期层的微小输入变化在后续层中被极大地放大,这是深度神经网络中常见的问题。为了解决这个问题,通常有两种方法:残差连接和层归一化(在论文的第 3.1 节只是简单提及)。
- 残差连接 (Residual connections): 残差连接的做法很简单,就是把层的输入加到它的输出上。比如,我们把初始嵌入加到注意力机制的输出上。残差连接有助于减轻梯度消失问题。其基本思想是,如果梯度太小,我们可以通过加上输入来让输出的梯度增大。公式表达非常直接:
我们将对注意力机制的输出和前馈层(feed-forward layer)的输出应用这种方法。
- 层归一化 (Layer normalization): 层归一化是一种标准化层输入的技术,它在嵌入的维度上进行归一化。其核心思想是,我们希望通过标准化层的输入,使它们的均值为 0,标准偏差为 1,从而促进梯度流动。这个公式乍一看可能不太简单:
每个参数的含义如下:
- 是嵌入的平均值
- 是嵌入的标准偏差
- 是一个微小的数值,用来避免除以零的情况。在标准偏差为 0 的情况下,这个小量 能够保证计算的进行。
- 和 是通过学习得到的参数,用于控制缩放和偏移步骤。
与批量标准化(如果你对此不了解也没关系)不同,层标准化是沿着嵌入维度进行的 - 意味着每个嵌入不会受批次中其他样本的影响。我们之所以进行层标准化,是希望使每层的输入都具有 0 的均值和 1 的标准差。
为什么要引入可学习的参数 和 呢?这是因为我们不希望标准化操作削弱层的表达能力。如果只是简单地标准化输入,可能会丢失重要信息。通过引入可调整的参数,我们可以学会如何调整和偏移这些标准化的值。
将这些方程结合起来,整个编码器的计算公式可能如下所示:
现在,让我们以之前的 E 和 Z 为例来试验一下!
让我们现在计算层标准化(Layer Normalization),我们可以将其分为三个步骤:
- 计算每个嵌入(embedding)的均值和方差。
- 通过从其行的均值中减去并除以其行方差的平方根(加上一个小数以避免除以零)进行标准化。
- 通过乘以缩放系数 gamma 并加上偏移量 beta 来调整尺度和位置。
5.3.1 平均值和方差
对于第一个嵌入
我们也可以对第二个嵌入做同样的计算。虽然我们跳过了计算步骤,但你可以理解其过程。
现在,让我们用 Python 来确认一下:
(embedding + Z).mean(axis=-1, keepdims=True)
array([[-4.58837567],[-3.59559107]])
(embedding + Z).std(axis=-1, keepdims=True)
array([[ 9.92061529],[10.50653019]])
太棒了!现在让我们开始进行标准化处理。
5.3.2 归一化
归一化是一个重要步骤,在此过程中,我们会对嵌入向量中的每个数值进行调整:即减去其平均值,然后除以标准差。这里的 Epsilon 是一个极小的数值,比如 0.00001。为了简化计算,我们假设 和 。
归一化的计算公式如下所示:
关于第二个嵌入向量的归一化,我们这里不再进行手工计算,而是直接通过编程代码来验证。接下来,让我们修改 encoder_block
函数,将这个归一化步骤纳入其中:
def layer_norm(x, epsilon=1e-6):mean = x.mean(axis=-1, keepdims=True)std = x.std(axis=-1, keepdims=True)return (x - mean) / (std + epsilon)def encoder_block(x, WQs, WKs, WVs, W1, b1, W2, b2):Z = multi_head_attention(x, WQs, WKs, WVs)Z = layer_norm(Z + x)output = feed_forward(Z, W1, b1, W2, b2)return layer_norm(output + Z)
layer_norm(Z + embedding)
array([[ 1.71887693, -0.56365339, -0.40370747, -0.75151608],[ 1.71909039, -0.56050453, -0.40695381, -0.75163205]])
看起来效果不错!现在,我们尝试将嵌入向量通过六个编码器进行处理:
def encoder(x, n=6):for _ in range(n):x = random_encoder_block(x)return xencoder(embedding)
array([[-0.335849 , -1.44504571, 1.21698183, 0.56391289],[-0.33583947, -1.44504861, 1.21698606, 0.56390202]])
惊人!我们得到的这些值非常有意义,而且没有出现 NaNs(不是数字的值)!编码器堆叠的核心思想在于,它们能输出一个连续的表示,即 z,有效捕捉输入序列的含义。这个表示随后被传递给解码器,解码器会逐个元素地生成符号的输出序列。
在深入探讨解码器之前,这里有一张来自 Jay 博客文章中的精彩图片:
编码器和解码器
你应该能够解释图中左侧的每个组件!相当令人印象深刻,不是吗?现在,让我们继续了解解码器。
解码器
解码器的很多设计思路都源自我们在编码器中的学习!解码器包含两个自注意力层:一个服务于编码器,另一个服务于解码器本身。此外,解码器还配备了一个前馈层(feed-forward layer)。下面我们一起来详细了解这些部分。
解码器模块接受两个输入:编码器的输出和生成的输出序列。编码器的输出代表了输入序列。在推断过程中,生成的输出序列以一个特殊的序列开始标记(SOS)为起点。在训练时,目标输出序列实际上是向后移动一位的输出序列。这个过程很快就会变得明朗!
解码器基于编码器生成的嵌入和 SOS 标记来生成序列的下一个 token,比如“hola”。解码器采用自回归方式工作,即利用之前生成的 token 来再次生成下一个 token。
- 第一次迭代:输入为 SOS,输出为“hola”
- 第二次迭代:输入为 SOS + “hola”,输出为“mundo”
- 第三次迭代:输入为 SOS + “hola” + “mundo”,输出为 EOS
在这里,SOS 代表序列开始标记,而 EOS 代表序列结束标记。解码器在生成 EOS 标记时会停止工作。它一次仅生成一个 token。请注意,所有的迭代都是基于编码器生成的嵌入。
请注意
这种自回归的设计使得解码器的速度变慢。 与解码器不同,编码器能在一次前向传递中生成其嵌入,而解码器需要进行多次前向传递。这就是为什么一些仅使用编码器的架构(如 BERT 或句子相似度模型)比那些仅用解码器的架构(如 GPT-2 或 BART)运行得更快的原因之一。
现在我们来深入探讨每个步骤!与编码器一样,解码器由多个解码器块叠加而成。解码器块比编码器块更为复杂。其总体结构包括:
- (掩蔽的) 自注意力层
- 残差连接和层归一化
- 编码器 - 解码器注意力层
- 残差连接和层归一化
- 前馈层
- 残差连接和层归一化
我们已经熟悉了编号为 1, 2, 3, 5 和 6 的数学概念。观察下图右侧,你会看到这些你已经认识的部分(图中右边部分):
源自原始论文“注意力就是全部所需”的 Transformer 模型
1. 文本嵌入
解码器的第一步是将输入的 Token 进行嵌入。输入的 Token 是 SOS
,我们将对它进行嵌入处理。我们使用的嵌入维度将与编码器相同。假设嵌入向量如下所示:
2. 位置编码
接下来,我们会像对编码器那样,给嵌入加上位置编码。由于它的位置与“Hello”相同,所以我们使用的位置编码和之前一样:
- i = 0(偶数位): PE(0,0) = sin(0 / 10000^(0 / 4)) = sin(0) = 0
- i = 1(奇数位): PE(0,1) = cos(0 / 10000^(2*1 / 4)) = cos(0) = 1
- i = 2(偶数位): PE(0,2) = sin(0 / 10000^(2*2 / 4)) = sin(0) = 0
- i = 3(奇数位): PE(0,3) = cos(0 / 10000^(2*3 / 4)) = cos(0) = 1
3. 合并位置编码和嵌入
位置编码加到嵌入向量上,是通过将两者相加实现的:
4. 自注意力
解码器块的首个步骤是自注意力(self-attention)机制。幸运的是,我们已经编写了相关代码,可以直接应用!
d_embedding = 4n_attention_heads = 2E = np.array([[1, 1, 0, 1]])WQs = [np.random.randn(d_embedding, d_query) for _ in range(n_attention_heads)]WKs = [np.random.randn(d_embedding, d_key) for _ in range(n_attention_heads)]WVs = [np.random.randn(d_embedding, d_value) for _ in range(n_attention_heads)]Z_self_attention = multi_head_attention(E, WQs, WKs, WVs)Z_self_attention
array([[ 2.19334924, 10.61851198, -4.50089666, -2.76366551]])
注意
在推理时,过程相对简单。但在训练时,情况就比较复杂了。训练时,我们使用的是未标记的数据:通常是从网上抓取的大量文本数据。编码器的目标是捕捉输入信息的全部内容,而解码器的目标则是预测最可能出现的下一个 Token(Token)。这意味着解码器只能利用目前已生成的 Token,不能提前看到后续的 Token。
因此,我们采用了掩蔽自注意力机制:将尚未生成的 Token 进行掩蔽。这是通过将注意力分数设为负无穷(-inf)来实现的,原始论文的第 3.2.3.1 节有所提及。我们暂时不深入这一点,但需牢记,在训练期间,解码器的结构会更为复杂。
5. 残差连接和层归一化
这里没有什么特别之处,我们只需将输入与自注意力的输出相加,然后进行层归一化处理。我们将继续使用之前的代码。
Z_self_attention = layer_norm(Z_self_attention + E)Z_self_attention
array([[ 0.17236212, 1.54684892, -1.0828824 , -0.63632864]])
6. 编码器与解码器的注意力互动
这一部分是全新的内容! 如果你好奇编码器生成的嵌入(embeddings)何时发挥作用,那么现在正是它们闪耀的时刻!
让我们假设编码器的输出是如下矩阵:
在自注意力(self-attention)机制中,我们会从输入嵌入中计算出查询(queries)、键(keys)和值(values)。
而在编码器 - 解码器注意力机制中,我们则从上一层解码器计算查询,而从编码器输出中计算键和值!所有的数学计算步骤都与之前相同;唯一的不同在于用于计算查询的嵌入是哪一个。接下来让我们通过一些代码来更深入理解。
def encoder_decoder_attention(encoder_output, attention_input, WQ, WK, WV):# The next three lines are the key difference!K = encoder_output @ WK # Note that now we pass the previous encoder output!V = encoder_output @ WV # Note that now we pass the previous encoder output!Q = attention_input @ WQ # Same as self-attention# This stays the samescores = Q @ K.Tscores = scores / np.sqrt(d_key)scores = softmax(scores)scores = scores @ Vreturn scoresdef multi_head_encoder_decoder_attention(encoder_output, attention_input, WQs, WKs, WVs):# Note that now we pass the previous encoder output!attentions = np.concatenate([encoder_decoder_attention(encoder_output, attention_input, WQ, WK, WV)for WQ, WK, WV in zip(WQs, WKs, WVs)],axis=1,)W = np.random.randn(n_attention_heads * d_value, d_embedding)return attentions @ W
WQs = [np.random.randn(d_embedding, d_query) for _ in range(n_attention_heads)]WKs = [np.random.randn(d_embedding, d_key) for _ in range(n_attention_heads)]WVs = [np.random.randn(d_embedding, d_value) for _ in range(n_attention_heads)]encoder_output = np.array([[-1.5, 1.0, -0.8, 1.5], [1.0, -1.0, -0.5, 1.0]])Z_encoder_decoder = multi_head_encoder_decoder_attention(encoder_output, Z_self_attention, WQs, WKs, WVs)Z_encoder_decoder
array([[ 1.57651431, 4.92489307, -0.08644448, -0.46776051]])
看,这样就可以实现了!你可能会问:“为什么我们要这么做?”其实,这是因为我们希望解码器能够集中注意力在输入文本的关键部分(比如,“hello world”)。编码器 - 解码器的这种注意力机制允许解码器中的每个位置都能覆盖输入序列中的全部位置。这对于诸如翻译这样的任务来说非常有用,因为解码器需要集中于输入序列中的关键部分。解码器将通过学习如何生成正确的输出 Token 来实现对输入序列关键部分的关注。这是一个非常强大的功能!
7. 残差连接与层归一化
这部分与之前的内容是一致的!
Z_encoder_decoder = layer_norm(Z_encoder_decoder + Z)Z_encoder_decoder
array([[-0.44406723, 1.6552893 , -0.19984632, -1.01137575]])
8. 前馈层
这一部分再次与之前相同!在这之后,我还会进行残差连接和层归一化的操作。
W1 = np.random.randn(4, 8)W2 = np.random.randn(8, 4)b1 = np.random.randn(8)b2 = np.random.randn(4)output = feed_forward(Z_encoder_decoder, W1, b1, W2, b2) + Z_encoder_decoderoutput
array([[-0.97650182, 0.81470137, -2.79122044, -3.39192873]])
9. 一网打尽:随机解码器
我们来编写一个解码器模块的代码。这里的主要变化是新增了一个额外的注意力机制。
d_embedding = 4d_key = d_value = d_query = 3d_feed_forward = 8n_attention_heads = 2encoder_output = np.array([[-1.5, 1.0, -0.8, 1.5], [1.0, -1.0, -0.5, 1.0]])def decoder_block(x,encoder_output,WQs_self_attention, WKs_self_attention, WVs_self_attention,WQs_ed_attention, WKs_ed_attention, WVs_ed_attention,W1, b1, W2, b2,):# Same as beforeZ = multi_head_attention(x, WQs_self_attention, WKs_self_attention, WVs_self_attention)Z = layer_norm(Z + x)# The next three lines are the key difference!Z_encoder_decoder = multi_head_encoder_decoder_attention(encoder_output, Z, WQs_ed_attention, WKs_ed_attention, WVs_ed_attention)Z_encoder_decoder = layer_norm(Z_encoder_decoder + Z)# Same as beforeoutput = feed_forward(Z_encoder_decoder, W1, b1, W2, b2)return layer_norm(output + Z_encoder_decoder)def random_decoder_block(x, encoder_output):# Just a bunch of random initializationsWQs_self_attention = [np.random.randn(d_embedding, d_query) for _ in range(n_attention_heads)]WKs_self_attention = [np.random.randn(d_embedding, d_key) for _ in range(n_attention_heads)]WVs_self_attention = [np.random.randn(d_embedding, d_value) for _ in range(n_attention_heads)]WQs_ed_attention = [np.random.randn(d_embedding, d_query) for _ in range(n_attention_heads)]WKs_ed_attention = [np.random.randn(d_embedding, d_key) for _ in range(n_attention_heads)]WVs_ed_attention = [np.random.randn(d_embedding, d_value) for _ in range(n_attention_heads)]W1 = np.random.randn(d_embedding, d_feed_forward)b1 = np.random.randn(d_feed_forward)W2 = np.random.randn(d_feed_forward, d_embedding)b2 = np.random.randn(d_embedding)return decoder_block(x, encoder_output,WQs_self_attention, WKs_self_attention, WVs_self_attention,WQs_ed_attention, WKs_ed_attention, WVs_ed_attention,W1, b1, W2, b2,)
def decoder(x, decoder_embedding, n=6):for _ in range(n):x = random_decoder_block(x, decoder_embedding)return xdecoder(E, encoder_output)
array([[ 0.71866458, -1.72279956, 0.57735876, 0.42677623]])
生成输出序列
现在,让我们来整合所有这些组成部分来生成输出序列。
- 我们有一个编码器,它处理输入序列并生成其详尽的表达。它是由多个编码器模块堆叠而成。
- 我们还有一个解码器,它接受编码器的输出和生成的 Token,来生成输出序列。它也是由多个解码器模块堆叠而成。
那么我们如何将解码器的输出转换成单词呢?我们需要在解码器顶部加上一个最终的线性层和 softmax 层。整个算法的流程如下:
- 编码器接收输入序列并生成其表示。
- 解码器从起始符号 Token 和编码器输出开始,生成输出序列的下一个 Token。
- 接下来,我们使用线性层来生成 logits。
- 然后,我们应用 softmax 层来产生概率。
- 解码器利用编码器的输出和之前生成的 Token 来生成输出序列的下一个 Token。
- 我们重复第 2 至 5 步,直到生成结束符号 Token。
这在论文的第 3.4 节中有提及。
1. 线性层
线性层实际上是一种简单的数学变换方法。它的作用是将解码器产生的数据转化为一个特定长度的向量,这个长度就是我们所说的词汇表的大小。举个例子,如果我们的词汇表里有 10000 个不同的词,那么线性层就会把解码器的输出转换成一个包含 10000 个元素的向量。每个元素代表对应单词成为下一个单词的可能性。假设我们的词汇表仅包含 10 个词,并且第一次解码器输出的是一个很简单的向量,比如 [1, 0, 1, 0]。这时,我们会用一个随机生成的权重矩阵和偏差矩阵,它们的大小是 词汇表大小(vocab_size)
乘以 解码器输出大小(decoder_output_size)
。
2. Softmax
所谓的 logits 虽然重要,但要理解它们并不简单。为了转换成我们可以理解的概率值,需要用到一个叫做 softmax 的函数。
softmax(x)
array([[0.01602618, 0.06261303, 0.38162024, 0.03087794, 0.0102383 ,0.00446011, 0.01777314, 0.00068275, 0.46780959, 0.00789871]])
通过这个过程,我们得到了一组概率数据!假设我们的词汇表是这样的:
根据上述数据,我们得到以下概率:
- hello: 0.01602618
- mundo: 0.06261303
- world: 0.38162024
- how: 0.03087794
- ?: 0.0102383
- EOS: 0.00446011
- SOS: 0.01777314
- a: 0.00068275
- hola: 0.46780959
- c: 0.00789871
在这些候选词中,最可能作为下一个词出现的是“hola”。这种方法,即总是选择最可能的词,被称为贪婪解码。虽然这种方法直接有效,但并不总是最优选择,有时可能导致不太理想的结果。关于生成技术的更多信息,您可以参阅这篇精彩的博客文章。
3. 随机编码器 - 解码器 Transformer
我们来一步步编写这个程序吧!首先,我们需要创建一个字典来为每个单词设定一个初始的嵌入(embedding)值。虽然这些值在训练过程中会进行学习和调整,但在这里我们先用随机生成的数值。
vocabulary = ["hello","mundo","world","how","?","EOS","SOS","a","hola","c",]embedding_reps = np.random.randn(10, 1, 4)vocabulary_embeddings = {word: embedding_reps[i] for i, word in enumerate(vocabulary)}vocabulary_embeddings
{'hello': array([[-1.19489531, -1.08007463, 1.41277762, 0.72054139]]),'mundo': array([[-0.70265064, -0.58361306, -1.7710761 , 0.87478862]]),'world': array([[ 0.52480342, 2.03519246, -0.45100608, -1.92472193]]),'how': array([[-1.14693176, -1.55761929, 1.09607545, -0.21673596]]),'?': array([[-0.23689522, -1.12496841, -0.03733462, -0.23477603]]),'EOS': array([[ 0.5180958 , -0.39844119, 0.30004136, 0.03881324]]),'SOS': array([[ 2.00439161, 2.19477149, -0.84901634, -0.89269937]]),'a': array([[ 1.63558337, -1.2556952 , 1.65365362, 0.87639945]]),'hola': array([[-0.5805717 , -0.93861149, 1.06847734, -0.34408367]]),'c': array([[-2.79741142, 0.70521986, -0.44929098, -1.66167776]])}
接下来,我们编写一个名为 generate
的方法,它能够自动地一步接一步地产生 tokens(词元)。
def generate(input_sequence, max_iters=10):# We first encode the inputs into embeddings# This skips the positional encoding step for simplicityembedded_inputs = [vocabulary_embeddings[token][0] for token in input_sequence]print("Embedding representation (encoder input)", embedded_inputs)# We then generate an embedding representationencoder_output = encoder(embedded_inputs)print("Embedding generated by encoder (encoder output)", encoder_output)# We initialize the decoder output with the embedding of the start tokensequence = vocabulary_embeddings["SOS"]output = "SOS"# Random matrices for the linear layerW_linear = np.random.randn(d_embedding, len(vocabulary))b_linear = np.random.randn(len(vocabulary))# We limit number of decoding steps to avoid too long sequences without EOSfor i in range(max_iters):# Decoder stepdecoder_output = decoder(sequence, encoder_output)logits = linear(decoder_output, W_linear, b_linear)probs = softmax(logits)# We get the most likely next tokennext_token = vocabulary[np.argmax(probs)]sequence = vocabulary_embeddings[next_token]output += " " + next_tokenprint("Iteration", i,"next token", next_token,"with probability of", np.max(probs),)# If the next token is the end token, we return the sequenceif next_token == "EOS":return outputreturn output
现在就让我们试运行它吧!
generate(["hello", "world"])
Embedding representation (encoder input) [array([-1.19489531, -1.08007463, 1.41277762, 0.72054139]), array([ 0.52480342, 2.03519246, -0.45100608, -1.92472193])]Embedding generated by encoder (encoder output) [[-0.15606365 0.90444064 0.82531037 -1.57368737][-0.15606217 0.90443936 0.82531082 -1.57368802]]Iteration 0 next token how with probability of 0.6265258176587956Iteration 1 next token a with probability of 0.42708031743571Iteration 2 next token c with probability of 0.44288777368698484
'SOS how a c'
结果出来了,我们得到了 tokens“how”, “a”, 和“c”。虽然这听起来并不像是一段流畅的翻译,但这在我们的预料之中。毕竟,我们此处只使用了随机产生的权重!
我建议你再仔细阅读原论文,了解一下 Transformer 在编码器和解码器方面的整体架构设计:
编码器和解码器
结论
希望这篇内容既有趣又让人增长见识!我们讨论了许多重要内容。是不是就这些了呢?答案基本是肯定的!尽管新的 Transformer 架构加入了许多新技巧,但我们刚讨论的内容便是其核心。根据你要解决的具体任务,你可以选择只使用编码器或解码器。例如,对于理解为主的任务,比如分类,你可以在编码器层上加一层线性层。对于以生成为主的任务,比如翻译,你则需要同时使用编码器和解码器。最后,对于像 ChatGPT 或 Mistral 这样的自由生成任务,你只需使用解码器即可。
当然,我们在解释中做了一些简化。让我们快速回顾一下原始 Transformer 论文中的一些关键数字:
- 嵌入维度:512(我们的例子中是 4)
- 编码器数量:6(与我们的例子一致)
- 解码器数量:6(与我们的例子一致)
- 前馈维度:2048(我们的例子中是 8)
- 注意力头数:8(我们的例子中是 2)
- 注意力维度:64(我们的例子中是 3)
我们讨论了很多话题,但有趣的是,通过扩大这些数学运算并进行巧妙的训练,我们能够取得令人瞩目的成果。这篇博客文章没有涉及训练内容,因为我们的目标是理解在使用现有模型时的数学原理,但我希望这为你深入了解训练部分提供了坚实的基础。希望你喜欢这篇博客!
练习
以下是一些练习题,帮助你加深对 Transformer 的理解。
- 定位编码的目的是什么?
- 自注意力与编码器 - 解码器注意力有什么区别?
- 如果我们的注意力维度太小会怎样?如果太大又会如何?
- 简述前馈层的结构。
- 解码器为什么比编码器运行速度慢?
- 残差连接和层归一化的作用是什么?
- 我们如何从解码器的输出得到概率值?
- 为什么每次都选择最可能的下一个 token 会有问题?