在这篇博客中,咱们将经过一个端到端的示例来解说 Transformer 模型中的数学原理。咱们的方针是对模型的工作原理有一个杰出的了解。为了使内简略于了解,咱们会进行许多简化。咱们将削减模型的维度,以便咱们能够手动推理模型的核算进程。例如,咱们将运用 4 维的嵌入向量替代原始的 512 维嵌入向量。这样做能够更简略手动推理数学核算进程!咱们将运用随机的向量和矩阵初始化,但假如你想一同动手试一试的话,你也能够运用自己的值。
如你所见,这些数学原理并不杂乱。杂乱性来自于进程的数量和参数的数量。我主张你在阅览本博文之前阅览 (或一同对照阅览)图解 Transform (The Illustrated Transformer) 这篇博客。这篇博客运用图解十分直观地解说了 Transformer 模型,我不打算再重复解说那里现已解说过的内容。我的方针是解说 Transformer 模型的“how”,而不是“what”。假如你想深化了解,能够查阅闻名的原始论文: Attention is all you need 。
预备常识
需求根本的线性代数基础常识——咱们主要进行简略的矩阵乘法,所以不需求十分精通。除此之外,对机器学习和深度学习的根本了解也会对了解本文有协助。
本文内容
- 经过一个端到端的示例来解说 Transformer 模型在推理进程中的数学原理
- 解说留意力机制
- 解说残差衔接和层归一化
- 供给一些代码来扩展模型!
言归正传,让咱们开端吧!原始的 Transformer 模型由编码器和解码器两部分组成。咱们的方针是将运用 Transform 模型制作一个翻译器!咱们首先将重点放在编码器部分。
编码器
编码器的方针是生成输入文本的丰厚嵌入表明。这个嵌入将捕捉输入的语义信息,并传递给解码器生成输出文本。编码器由 N 层堆叠而成。在咱们深化了解这些层之前,咱们需求了解怎么将单词 (或 token ) 传递给模型。
阐明
嵌入 (Embeddings) 是一个有点过度运用的术语。咱们首先创建一个文本的嵌入,它将作为编码器的输入。编码器还会输出一个嵌入 (有时也称为隐藏状态)。解码器也会接纳一个嵌入! 嵌入的整个意图是将单词 (或 token ) 表明为向量。
1. 文本嵌入
假定咱们想将英文的“Hello World”翻译成西班牙语。榜首步是运用文本嵌入算法将每个输入 token 转换为向量。文本嵌入算法的编码方式是经过许多文本学习到的。一般咱们运用比较大的向量巨细,比如 512,这样能够有更加丰厚的语义表明能力;但为了方便起见,咱们在这个比如中运用巨细为 4 的向量。这样咱们能够更简略地进行数学核算。
Hello -> [1,2,3,4] World -> [2,3,4,5]
这样咱们就能够将输入表明为一个矩阵。
阐明
虽然咱们能够运用单独的两个向量表明 Hello World 的文本嵌入,但将它们作为单个矩阵管理会更简略。这是由于咱们能够运用矩阵乘法简化运算!
2. 方位编码
同一单词出现在语句的不同方位或许会表明不同的语义,上述的文本嵌入没有表明单词在语句中方位的信息,所以咱们还需求运用一些办法表明一些方位信息。能够经过在文本嵌入中增加方位编码来实现这一点。供给单词在语句中的方位编码有许多种办法——咱们能够运用学习到的方位嵌入或固定的向量来表明。原始论文运用了固定的向量,由于他们发现两种办法几乎没有区别 (参见原始论文的 3.5 节)。咱们也将运用固定的向量。正弦和余弦函数具有波状模式,而且跟着长度的推移重复出现。经过运用这些函数,语句中的每个方位都会得到一组独特但共同的数字编码表明。下面是论文中运用的函数 (第 3.5 节),其间 pospos 表明输入序列中的方位,ii 表明编码向量的维度索引,dmodeld_{text{model}} 表明模型的维度:
这个想法是对文本嵌入中的每个值进行正弦和余弦之间的插值 (偶数索引运用正弦,奇数索引运用余弦)。让咱们运用之前“Hello World”的比如,运用维度为 4 的方位编码核算一下!
“Hello”是“Hello World”的榜首个字符,pos=0pos=0 其方位编码如下:
- 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”是“Hello World”的第二个字符,pos=1pos=1 其方位编码如下:
- 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]
所以咱们的新矩阵,也便是编码器的输入,现在是:
假如你之前看到过原始论文中的图片,咱们刚刚完结的是图片的左下部分 (嵌入 + 方位编码)。
4. 自留意力
4.1 矩阵界说
咱们现在介绍多头留意力 (Multi-head Attention) 的概念。留意力是一种机制,模型能够经过这种机制来操控输入的不同部分的重要程度。多头留意力指的是经过运用多个留意力头使模型能够同时重视来自不同表明子空间信息的办法。每个留意力头都有自己的 K、V 和 Q 矩阵。经过将多个留意力头的输出合并在一同,模型能够综合考虑来自不同留意力头的信息,从而取得更大局的了解和表达能力。
咱们在示例中运用 2 个留意力头。每个留意力头最开端将运用随机初始化的值替代。每个矩阵是一个 4×3 的矩阵。这样,每个矩阵能够把 4 维嵌入转换为 3 维的键矩阵 (K)、值矩阵 (K) 和查询矩阵 (Q)。这降低了留意力机制的维度,有助于降低核算杂乱性。留意,运用过小的留意力巨细会影响模型的功能。下面是咱们最开端生成的留意力头 (仅仅随机值):
榜首个留意力头
第二个留意力头
4.2 核算 K、V 和 Q 矩阵
现在,咱们需求将输入的文本嵌入与权重矩阵相乘,以取得 K (键矩阵)、V (值矩阵) 和 Q (查询矩阵) 矩阵。
核算 K 矩阵
这看起来有点杂乱,下面的核算也类似这样,假如手动核算会比较繁琐,而且或许会犯错。所以让咱们偷个懒,运用 NumPy 来帮咱们核算。
咱们首先界说矩阵:
import numpy as np
WK1 = 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 @ WK1
K1
array([[4. , 8. , 4. ], [6.84, 9.99, 6.84]])
核算 V 矩阵
V1 = embedding @ WV1
V1
array([[6. , 6. , 4. ],
[7.99, 8.84, 6.84]])
核算 Q 矩阵
Q1 = embedding @ WQ1
Q1
array([[8. , 3. , 3. ],
[9.99, 3.99, 4. ]])
现在,让咱们先越过第二个留意力头,先完结单留意力头的悉数核算。稍后咱们再回来核算第二个留意力头,终究组成多留意力头。
4.3 留意力核算
核算留意力分数需求几个进程:
- 核算 Q 向量与每个 K 向量的点积
- 将成果除以 K 向量维度的平方根
- 将成果输入 softmax 函数以取得留意力权重
- 将每个 V 向量乘以留意力权重
4.3.1 查询与每个键的点积
核算“Hello”的分数需求核算 q1 与每个 K 向量 (k1 和 k2) 的点积 (相似度分数):
假如用矩阵表明的话,这将是 Q1 乘以 K1 的转置 (成果的榜首行是“Hello”的分数,第二行是“World”的分数):
由于我手动核算简略犯错,所以让咱们再次用 Python 承认一下:
scores1 = Q1 @ K1.T
scores1
array([[ 68. , 105.21 ],
[ 87.88 , 135.5517]])
4.3.2 除以 K 向量维度的平方根
然后,咱们将分数除以 K 向量维度 d (本例中为 d=3,但在原始论文中为 64) 的平方根。为什么要这样做呢?关于较大的 d 值,点积会变得过大 (毕竟,咱们正在进行一堆数字的乘法,会导致值变大)。而且大的值是欠好的!咱们很快会具体讨论这个问题。
scores1 = scores1 / np.sqrt(3)
scores1
array([[39.2598183 , 60.74302182],
[50.73754166, 78.26081048]])
4.3.3 运用 softmax 函数
然后,咱们运用 softmax 函数进行归一化,使它们都是正数且总和为 1。
:::{.callout-note title=”什么是softmax函数?”} Softmax 是一个函数,它承受一个值向量并回来一个介于 0 和 1 之间的值向量,其间向量的总和为 1。这是一种取得概率的好办法。它的界说如下:
不要被公式吓到 ——它实际上十分简略。假定咱们有以下向量:
这个向量的 softmax 成果将是:
正如你所看到的,这些值都是正数,且总和为 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 将 V 矩阵乘以留意力权重
然后,咱们将 V 矩阵乘以留意力权重:
attention1 = scores1 @ V1
attention1
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 @ WK
V = x @ WV
Q = x @ WQ
scores = Q @ K.T
scores = scores / np.sqrt(3)
scores = softmax(scores)
scores = scores @ V
return 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。实际上,咱们初始的嵌入矩阵的值或许太大了,导致 K、V 和 Q 矩阵的值很大,并跟着它们的相乘而增长。
还记得咱们为什么要除以 K 向量的维度的平方根吗?这便是咱们这样做的原因。假如咱们不这样做,点积的值将会过大,导致 softmax 后的值也很大。但是,在这种状况下,似乎除以 3 的平方根还不够!一种临时的处理办法是咱们能够将值按照更小的份额缩放。让咱们从头界说留意力函数,但是这次将其缩小 30 倍。这不是一个好的长时间处理方案,但它将协助咱们取得不同的留意力分数。稍后咱们会找到更好的处理方案。
def attention(x, WQ, WK, WV):
K = x @ WK
V = x @ WV
Q = x @ WQ
scores = Q @ K.T
scores = scores / 30 # we just changed this
scores = softmax(scores)
scores = scores @ V
return 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 留意力头的输出
编码器的下一层期望得到是一个矩阵,而不是两个矩阵 (这儿有 2 个留意力头)。榜首步是将两个留意力头的输出衔接起来 (原始论文的 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.9> 1937741,
7.84804146]])
终究,咱们将这个衔接的矩阵乘以一个权重矩阵,以取得留意力层的终究输出。这个权重矩阵也是能够学习的!矩阵的维度保证与咱们文本嵌入的维度相同 (在咱们的比如中为 4)。
# Just some random values
W = 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 @ W
Z
array([[ 11.46394285, -13.18016471, -11.59340253, -17.04387829],
[ 11.62608573, -13.47454936, -11.87126395, -17.4926367 ]])
图解 Transform 顶用一张图片表明了上述的核算进程:
5. 前馈层
5.1 根本的前馈层
在自留意力层之后,编码器有一个前馈神经网络 (FFN)。这是一个简略的网络,包括两个线性变换和一个 ReLU 激活函数。 图解Transform 中没有具体介绍它,所以让我扼要解说一下。FFN 的方针是处理和转换留意机制产生的表明。一般的流程如下 (参见原论文的第 3.3 节):
-
榜首个线性层: 一般会扩展输入的维度。例如,假如输入维度是 512,输出维度或许是 2048。这样做是为了使模型能够学习更杂乱的函数。在咱们的简略示例中,维度从 4 扩展到 8。
-
ReLU 激活: 这是一个非线性激活函数。它是一个简略的函数,假如输入是负数,则回来 0;假如输入是正数,则回来输入自身。这使得模型能够学习非线性函数。其数学表达如下:
ReLU(x)={0ifx<0xifx≥0ReLU(x) = begin{cases} 0 & text{if } x < 0 \ x & text{if } x geq 0 end{cases} -
第二个线性层: 这是榜首个线性层的逆操作。它将维度降低回原始维度。在咱们的示例中,维度将从 8 降低到 4。
FFN(x)=ReLU(xW1+b1)W2+b2text{FFN}(x) = text{ReLU}(xW_1 + b_1)W_2 + b_2
咱们能够将一切这些表明如下:
留意,该层的输入是咱们在上面的自留意力中核算得到的 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 悉数封装起来: 随机编码器 (Random Encoder)
现在让咱们编写一些代码,将多头留意力和前馈层悉数放在编码器块中。
:::{.callout-note} 这段代码的优化方针是了解和学习,并非为了最佳功能!请不要过于严苛地评判! :::
d_embedding = 4
d_key = d_value = d_query = 3
d_feed_forward = 8
n_attention_heads = 2
def attention(x, WQ, WK, WV):
K = x @ WK
V = x @ WV
Q = x @ WQ
scores = Q @ K.T
scores = scores / np.sqrt(d_key)
scores = softmax(scores)
scores = scores @ V
return scores
def 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 @ W
def feed_forward(Z, W1, b1, W2, b2):
return relu(Z.dot(W1) + b1).dot(W2) + b2
def 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 Z
def 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]])
太棒了!这仅仅一个编码器块。原始论文运用了 6 个编码器。一个编码器的输出进入下一个编码器,依此类推。
def encoder(x, n=6):
for _ in range(n):
x = random_encoder_block(x)
return x
encoder(embedding)
/tmp/ipykernel_11906/1045810361.py:2: RuntimeWarning: overflow encountered in exp
return np.exp(x)/np.sum(np.exp(x),axis=1, keepdims=True)
/tmp/ipykernel_11906/1045810361.py:2: RuntimeWarning: invalid value encountered in divide
return np.exp(x)/np.sum(np.exp(x),axis=1, keepdims=True)
array([[nan, nan, nan, nan],
[nan, nan, nan, nan]])
5.3 残差衔接和层归一化
糟糕!咱们得到了 NaN 值!看起来咱们的值太大了,当传递给下一个编码器时,它们变得太大从而发散了!这被称为梯度爆破。在没有任何归一化的状况下,前期层输入的细小变化会在后续层中被扩大。这是深度神经网络中常见的问题。有两种常见的技能能够缓解这个问题: 残差衔接和层归一化 (论文中第 3.1 节中简略说到)。
-
残差衔接: 残差衔接便是将层的输入与其输出相加。例如,咱们将初始嵌入增加到留意力的输出中。残差衔接能够缓解梯度消失问题。其直观了解是,假如梯度太小,咱们能够将输入增加到输出中,梯度就会变大。数学上很简略:
Residual(x)=x+Layer(x)text{Residual}(x) = x + text{Layer}(x)
便是这样!咱们将对留意力的输出和前馈层的输出都进行残差衔接。
-
层归一化 (Layer normalization): 层归一化是一种对层输入进行归一化的技能。它在文本嵌入维度上进行归一化。其直观了解是,咱们期望对单层的输入进行归一化,使其具有均值为 0 和标准差为 1。这有助于梯度的活动。乍一看,数学公式并不那么简略。
LayerNorm(x)=x−2++text{LayerNorm}(x) = frac{x – mu}{sqrt{sigma^2 + epsilon}} times gamma + beta
让咱们解说一下每个参数的意义:
- mu 是文本嵌入的均值
- sigma 是文本嵌入的标准差
- epsilon 是一个较小的数,用于防止除以零。假如标准差为 0,这个小的 epsilon 就派上了用场!
- gamma 和 beta 是可学习参数,用于操控缩放平和移。
与批归一化 (batch normalization) 不同 (假如你不知道它是什么也没关系),层归一化是在文本嵌入维度上进行归一化的,这意味着每个文本嵌入都不会受到 batch 中其他样本的影响。其直观了解是,咱们期望对层的输入进行归一化,使其具有均值为 0 和标准差为 1。
为什么要增加可学习的参数 gamma 和 beta ?原因是咱们不想失掉层的表明能力。假如咱们只对输入进行归一化,或许会丢掉一些信息。经过增加可学习的参数,咱们能够学习缩放平和移归一化后的值。
将这些方程组合起来,整个编码器的方程或许如下所示:
让咱们运用之前的 E 和 Z 值测验一下!
现在让咱们核算层归一化,咱们能够分为三个进程:
- 核算每个文本嵌入的均值和方差。
- 经过减去其行的均值并除以其行方差的平方根 (加上一个小数以防止除以零) 进行归一化。
- 经过乘以 gamma 并加上 beta 进行缩放平和移。
5.3.1 均值和方差
关于榜首个文本嵌入 (“Hello”):
咱们能够对第二个文本嵌入 (“World”) 进行相同的操作。这儿咱们越过核算进程,但你应该能了解这个进程。
让咱们用 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。咱们假定 =1gamma=1 和 =0beta=0 ,这样能够简化核算。
关于第二个嵌入,咱们将越过手动核算的进程,直接用代码进行验证!让咱们从头界说修改后的 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 x
encoder(embedding)
array([[-0.335849 , -1.44504571, 1.21698183, 0.56391289],
[-0.33583947, -1.44504861, 1.21698606, 0.56390202]])
太棒了!这些值是有意义的,咱们没有得到 NaN 值!编码器的思想是它们输出一个接连的表明 Z,捕捉输入序列的意义。然后将该表明传递给解码器,解码器将逐一生成一个个符号的输出序列。
在深化研讨解码器之前,这儿有一张来自 Jay 博客的结构清晰的图片:
你应该能够了解左边的每个组件!适当令人印象深入,对吧?现在让去看看解码器。
解码器
大部分咱们在编码器中学到的内容也会在解码器中运用!解码器有两个自留意力层,一个用于编码器,一个用于解码器。解码器还有一个前馈层。让咱们逐一介绍一下这些内容。
解码器块接纳两个输入: 编码器的输出和现已生成的解码器的输出序列。在推理进程中,将从特别的开始序列符号 (SOS) 开端顺次生成输出序列。在操练进程中,解码器需求猜测方针输出序列的后一个字符并于真实的作比较。接下来咱们将用一个比如来解说这个进程!
将文本嵌入和 SOS 符号输入编码器,解码器将生成序列的下一个 token。解码器是自回归的,这意味着解码器将运用先前生成的 token 再次生成第二个 token。 (下面的比如中输出的是西班牙语)
- 迭代 1: 输入为 SOS,输出为“hola”
- 迭代 2: 输入为 SOS + “hola”,输出为“mundo”
- 迭代 3: 输入为 SOS + “hola” + “mundo”,输出为 EOS
在这儿,SOS 是开始序列符号,EOS 是完毕序列符号。当解码器生成 EOS 符号时,它将停止生成。它每次生成一个 token。请留意,每次的迭代进程都运用编码器生成的文本嵌入。
阐明
这种自回归规划使得解码器变得很慢。 编码器能够在一次前向传递中生成其文本嵌入,而解码器需求进行屡次前向传递逐一 token 生成。这是为什么仅运用编码器的架构 (如 BERT 或语义相似性模型) 比仅运用解码器的架构 (如 GPT-2 或 BART) 快得多的原因之一。
让咱们深化了解每个进程!和编码器一样,解码器由一系列解码器块组成。解码器块比编码器块略微杂乱一些。它的一般结构是:
- (带有掩码的) 自留意力层
- 残差衔接和层归一化
- 编码器-解码器留意力层
- 残差衔接和层归一化
- 前馈层
- 残差衔接和层归一化
咱们现已熟悉了 1、2、3、5 和 6 的一切数学常识。检查下面的图画右侧,相信你现已了解了这些块 (右侧部分):
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. 自留意力
解码器块中的榜首步是自留意力机制。走运的是,咱们现已之前现已写过自留意力的代码,能够直接运用!
d_embedding = 4
n_attention_heads = 2
E = 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 屏蔽掉。这是在原始论文中的做法 (第 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. 编码器-解码器留意力
这部分的内容与之前的有所不同! 假如你想知道编码器生成的文本嵌入在哪里发挥作用,那么现在便是它们展示自己的时刻!
假定编码器的输出是以下矩阵:
在编码器的自留意力机制中,咱们运用输入的文本嵌入核算 Q 矩阵 (queries)、K 矩阵 (keys) 和 V 矩阵 (values)。
在编码器-解码器留意力中,咱们运用前一个解码器层核算 Q 矩阵,运用编码器输出核算 K 矩阵和 V 矩阵!一切的数学核算都与之前相同;唯一的区别是核算 Q 矩阵时运用哪个文本嵌入。让咱们看一些代码:
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 same
scores = Q @ K.T
scores = scores / np.sqrt(d_key)
scores = softmax(scores)
scores = scores @ V
return scores
def 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]])
这个办法有用!你或许会问: “为什么要这样做呢?”原因是咱们期望解码器能够学习重视到于输入文本中与当时输出的 token 相关的部分 (例如,“hello world”)。编码器-解码器的留意力机制使得解码器的每个方位都能够获取输入序列中的一切方位的信息。这关于翻译等使命十分有协助,由于解码器需求专注于输入序列。经过学习生成正确的输出 token,解码器将学会重视输入序列的相关部分。这便是交叉自留意力机制 (cross-attention mechanism),一个十分强壮的机制!
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_decoder
output
array([[-0.97650182, 0.81470137, -2.79122044, -3.39192873]])
9. 悉数封装起来: 随机解码器 (Random Decoder)
让咱们编写整个解码器模块的代码。与编码器比较主要的变化是咱们现在有了一个额定的留意力机制。
d_embedding = 4
d_key = d_value = d_query = 3
d_feed_forward = 8
n_attention_heads = 2
encoder_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 before
Z = 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 before
output = 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 initializations
WQs_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 x
decoder(E, encoder_output)
array([[ 0.71866458, -1.72279956, 0.57735876, 0.42677623]])
生成输出序列
咱们现已有了一切的根本模块!现在让咱们生成输出序列。
- 咱们有编码器,它接纳输入序列并生成其丰厚的表明。它由一系列编码器块组成。
- 咱们有解码器,它接纳编码器的输出和之前生成的 token,并生成输出序列。它由一系列解码器块组成。
咱们怎么从解码器的输出得到一个单词呢?咱们需求在解码器的顶部增加一个终究的线性层和一个 softmax 层。整个算法看起来像这样:
- 编码器接纳输入序列并生成其表明。
- 解码器以 SOS 符号和编码器的输出作为起点,生成输出序列的下一个 token。
- 然后,咱们运用一个线性层来生成 logits。
- 然后,咱们运用一个 softmax 层来生成概率。
- 解码器运用编码器的输出和先前生成的 token 来生成输出序列的下一个 token。
- 咱们重复进程 2-5,直到生成 EOS 符号。
这在论文的第 3.4 节中说到。
1. 线性层
线性层是一个简略的线性变换。它接纳解码器的输出,并将其转换为巨细为 vocab_size
的向量。这个巨细对应的是词汇表的巨细。例如,假如咱们有一个包括 10000 个单词的词汇表,线性层将解码器的输出转换为巨细为 10000 的向量。这个向量将包括每个单词成为序列中下一个单词的概率。为简略起见,让咱们运用一个包括 10 个单词的词汇表,并假定榜首个解码器的输出是一个十分简略的向量: [1, 0, 1, 0]。咱们将运用一个随机生成的权重矩阵和偏置向量,它们的巨细是 vocab_size∗decoder_output_sizevocab_size * decoder_output_size 。
def linear(x, W, b):
return np.dot(x, W) + b
x = linear([[1, 0, 1, 0]], np.random.randn(4, 10), np.random.randn(10))
x
array([[-0.39929948, 0.96345013, 2.77090264, 0.25651866, -0.84738762,
-1.67834992, -0.29583529, -3.55515281, 2.97453801, -1.10682376]])
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
从中能够看出,最或许的下一个 token 是“hola”。每次都挑选最或许的 token 称为贪婪解码。这并不总是最好的办法,由于它或许导致次优成果,但咱们暂时不深化研讨生成技能。假如你想了解更多信息,请检查这篇十分 amazing 的 博客文章 。
3. 随机编码器-解码器的 Transformer
让咱们编写完整的代码!咱们界说一个将单词映射到它们初始文本嵌入的字典。请留意,这些初始值操练进程中也是经过学习取得的,但现在咱们将运用随机值。
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
办法来自回归地生成 token。
def generate(input_sequence, max_iters=10):
# We first encode the inputs into embeddings
# This skips the positional encoding step for simplicity
embedded_inputs = [
vocabulary_embeddings[token][0] for token in input_sequence
]
print("Embedding representation (encoder input)", embedded_inputs)
# We then generate an embedding representation
encoder_output = encoder(embedded_inputs)
print("Embedding generated by encoder (encoder output)", encoder_output)
# We initialize the decoder output with the embedding of the start token
sequence = vocabulary_embeddings["SOS"]
output = "SOS"
# Random matrices for the linear layer
W_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 EOS
for i in range(max_iters):
# Decoder step
decoder_output = decoder(sequence, encoder_output)
logits = linear(decoder_output, W_linear, b_linear)
probs = softmax(logits)
# We get the most likely next token
next_token = vocabulary[np.argmax(probs)]
sequence = vocabulary_embeddings[next_token]
output += " " + next_token
print(
"Iteration", i,
"next token", next_token,
"with probability of", np.max(probs),
)
# If the next token is the end token, we return the sequence
if next_token == "EOS":
return output
return 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.6265258176587956
Iteration 1 next token a with probability of 0.42708031743571
Iteration 2 next token c with probability of 0.44288777368698484
'SOS how a c'
好的,咱们得到了“how”、“a”和“c”这些 token。这不是一个好的翻译,但能够了解!由于咱们只运用了随机权重!
我主张你再次具体研讨原始论文中的整个编码器-解码器架构:
定论
期望这篇文章有趣且有利!咱们涵盖了许多内容。等等,这就完毕了吗?答案是,大部分是的!新的 Transformer 架构增加了许多技巧,但 Transformer 的中心便是咱们刚刚解说的内容。根据你想处理的使命,你也能够只运用编码器或解码器。例如,关于以了解为重的使命 (如分类),你能够运用堆叠的编码器和一个线性层。关于以生成为重的使命 (如翻译),你能够运用编码器和堆叠的解码器。终究,关于自在生成,如 ChatGPT 或 Mistral,你能够只运用堆叠的解码器。
当然,咱们也做了许多简化。让咱们扼要地看一下原始 Transformer 论文中的一些数字:
- 文本嵌入维度: 512 (在咱们的比如中为 4)
- 编码器数量: 6 (在咱们的比如中为 6)
- 解码器数量: 6 (在咱们的比如中为 6)
- 前馈维度: 2048 (在咱们的比如中为 8)
- 留意力头数: 8 (在咱们的比如中为 2)
- 留意力维度: 64 (在咱们的比如中为 3)
咱们刚刚涵盖了许多主题,经过扩展模型的巨细并进行智能操练,咱们能够实现令人印象深入的成果。由于本文的方针是了解现有模型的数学原理,所以咱们没有触及模型操练部分,但我期望能够为学习模型操练部分供给坚实的基础。期望你喜欢这篇博文!
操练
以下是一些操练,以检验你对 Transformer 的了解。
- 方位编码的意图是什么?
- 自留意力和编码器-解码器留意力有什么区别?
- 假如咱们的留意力维度太小会产生什么?假如太大呢?
- 扼要描述一下前馈层的结构。
- 为什么解码器比编码器慢?
- 残差衔接和层归一化的意图是什么?
- 咱们怎么从解码器的输出得到概率?
- 为什么每次都挑选最或许的下一个 token 会带来问题?
资源
原文: osanseviero.github.io/hackerllama…
作者: Omar Sanseviero
译者: yaoqih