引言
如今,计算自然语言处理(NLP)是一个迅速发展的领域,其中计算力量与语言学相结合。其中语言学的一部分主要归功于约翰·鲁珀特·弗斯的分布语义理论。他曾说过以下的名言:“你可以通过它的伙伴了解一个词的含义”。因此,一个词的语义表示取决于它所在的上下文。正是基于这一假设,Ashish Vaswani等人的论文“Attention is all you need” 才具有开创性的重要性。它将Transformer架构设定为许多迅速增长的工具的核心,如BERT、GPT4、Llama等。在本文中,我们将审查Transformer架构中编码器部分核心的关键数学操作。
标记化、embedding和向量空间
在处理NLP问题时,人们首先要面对的任务是如何对包含在一个句子中的信息进行编码,以便机器能够处理它。机器只能处理数字,这意味着单词、它们的含义、标点符号等都必须被转换为数值表示。这本质上是embedding问题。
在深入讨论embedding是什么之前,我们需要迈出一个中间步骤,讨论标记化。在这里,单词块或单词片段被定义为基本构建块(所谓的标记),它们将被稍后表示为数字。需要特别注意的是我们不能用一个数字来刻画一个单词或单词片段,因此我们使用数字列表(向量),这给了我们更大的表示能力。
它们是如何构建的?它们存在于哪个空间中?原始论文使用维度为512的标记向量表示。在这里,我们将使用将一组单词表示为向量的最简单方式。如果我们有一个由3个单词组成的句子‘Today is Sunday’,则句子中的每个单词都将用一个向量表示。最简单的形式,考虑到这仅仅是3个单词,是一个3维向量空间。例如,可以根据一种一热编码规则为每个单词分配向量:
‘Today’ — (1,0,0)
‘is’ — (0,1,0)
‘sunday’ — (0,0,1)
这个结构(由3个3维向量组成),虽然可以使用,但存在缺点。首先,它以使每个单词与任何其他单词都正交的方式embedding了这些单词,这意味着不能赋予单词之间的语义关系概念。关联向量之间的内积始终为零。
其次,这个特定的结构还可以用于表示由三个不同单词组成的任何其他句子。尝试表示由三个单词组成的不同句子时问题就出现了。对于3维空间,您只能拥有3个线性独立的向量。线性独立意味着集合中的任何向量都不能通过其他向量的线性组合形成。在one-hot编码的背景下,每个向量已经是线性独立的。因此,embedding可以处理的单词总数与向量空间的总维数相同。
流利的英语听众知道的单词平均数量约为30,000,这意味着我们需要使用这个大小的向量来处理任何典型的文本。这样一个高维空间带来了挑战,特别是在内存方面。请记住,每个向量只有一个非零分量,这将导致内存和计算资源的非常低效的使用。
然而,让我们坚持下去,以完成我们的例子。为了描述这个句子的至少一个简单变化,我们需要扩展我们的向量的大小。在这种情况下,让我们允许使用‘sunday’或‘saturday’。现在,每个单词由一个4维向量空间描述:
‘Today’ — (1,0,0,0)
‘is’ — (0,1,0,0)
‘sunday’ — (0,0,1,0)
‘saturday’ — (0,0,0,1)
我们句子中的3个单词可以堆叠在一起形成一个矩阵X,其中有3行4列:
import numpy as np
from scipy.special import softmax
import math
sentence = "Today is sunday"
vocabulary = ['Today', 'is', 'sunday', 'saturday']
# Initial Embedding (one-hot encoding):
x_1 = [1,0,0,0] # Today
x_2 = [0,1,0,0] # is
x_3 = [0,0,1,0] # Sunday
x_4 = [0,0,0,1] # Saturday
X_example = np.stack([x_1, x_2, x_3], axis=0)
单头注意力层:查询、键和值
从X开始,Transformer架构首先通过构建3组其他向量(即(3×4)矩阵)Q、K和V(查询、键和值)来启动。如果在网上查找它们,你会发现以下信息:查询是你要查找的信息,键是你必须提供的信息,而值是你实际获得的信息。通过与数据库系统的类比,这确实解释了这些对象的一些信息。尽管如此,我们认为对它们的核心理解来自于它们在模型架构中所扮演的数学角色。
矩阵Q、K和V是通过将X乘以3个其他形状为(4×4)的矩阵W^Q、W^K和W^V而构建的。这些W矩阵包含将在模型训练过程中进行调整的参数,即可学习参数。因此,W最初是随机选择的,并在每个句子(或在实践中是每批句子)的训练过程中进行更新。例如,让我们考虑以下3个W矩阵:
我们可以通过从-1到1的均匀分布中进行抽样来创建它们:
W_Q = np.random.uniform(-1, 1, size=(4, 4))
W_K = np.random.uniform(-1, 1, size=(4, 4))
W_V = np.random.uniform(-1, 1, size=(4, 4))
让我们创建一个抽象来存储我们的权重矩阵,以便稍后使用它们。
class W_matrices:
def __init__(self, n_lines, n_cols):
self.W_Q = np.random.uniform(low=-1, high=1, size=(n_lines, n_cols))
self.W_K = np.random.uniform(low=-1, high=1, size=(n_lines, n_cols))
self.W_V = np.random.uniform(low=-1, high=1, size=(n_lines, n_cols))
def print_W_matrices(self):
print('W_Q : \n', self.W_Q)
print('W_K : \n', self.W_K)
print('W_V : \n', self.W_V)
在与输入X相乘后,我们得到:
Q = np.matmul(X_example, W_Q)
K = np.matmul(X_example, W_K)
V = np.matmul(X_example, W_V)
接下来的步骤是将查询和键矩阵进行(点)乘法,以生成注意力分数。正如上面讨论的,得到的矩阵是Q和K集合中每对向量之间的点积(相似性)的结果:
Attention_scores = np.matmul(Q, np.transpose(K))
我们再次强调,基本上,注意力分数表示空间中向量的接近程度。也就是说,对于两个归一化向量,它们的点积越接近1,它们就越接近。这也意味着这些单词彼此更接近。因此,这种方式,模型考虑了单词在它们出现的句子的上下文中的接近度度量。
然后,矩阵A被除以4的平方根。此操作旨在避免渐变消失/激增的问题。这是因为维度为d_k的两个随机分布的均值为0,标准差为1的向量,产生的标量积也具有均值为0但标准差为d_k。由于下一步涉及对这些标量积值的指数运算,这意味着对于某些值,会有巨大的因子,如exp(d_k)(考虑到论文中实际使用的维度为512)。对于其他值,可能会有很小的因子,如exp(−d_k)。
Attention_scores = Attention_scores / 2
我们现在准备应用softmax映射:
其中x_i是一个通用向量的第i个分量。因此,它产生了一个概率分布。值得一提的是,这个函数仅对向量定义,而不对二维矩阵定义。当说softmax被应用于A_norm时,实际上softmax分别应用于A_norm的每一行(向量)。它假定了一个表示V中每个向量权重的一维向量堆叠的格式。这意味着通常应用于矩阵的操作,比如旋转,在这种情况下是没有意义的。这是因为我们不处理一个连贯的二维实体,而是一组只是碰巧排列在二维格式中的独立向量。
Softmax_Attention_Matrix = np.apply_along_axis(softmax, 1, Attention_scores)
我们的结果然后乘以V,得到单头注意力矩阵,这是初始V(也是初始X)的更新版本:
One_Head_Attention = np.matmul(Softmax_Attention_Matrix, V)
现在,让我们建立一个类来初始化我们的权重矩阵并实现计算单头注意力层的方法。请注意,我们只关心前向传递,因此诸如反向传播等方法将在后续文章中讨论。
class One_Head_Attention:
def __init__(self, d_model, X):
self.d_model = d_model
self.W_mat = W_matrices(d_model, d_model)
self.Q = np.matmul(X, self.W_mat.W_Q)
self.K = np.matmul(X, self.W_mat.W_K)
self.V = np.matmul(X, self.W_mat.W_V)
def print_QKV(self):
print('Q : \n', self.Q)
print('K : \n', self.K)
print('V : \n', self.V)
def compute_1_head_attention(self):
Attention_scores = np.matmul(self.Q, np.transpose(self.K))
print('Attention_scores before normalization : \n', Attention_scores)
Attention_scores = Attention_scores / np.sqrt(self.d_model)
print('Attention scores after Renormalization: \n ', Attention_scores)
Softmax_Attention_Matrix = np.apply_along_axis(softmax, 1, Attention_scores)
print('result after softmax: \n', Softmax_Attention_Matrix)
# print('Softmax shape: ', Softmax_Attention_Matrix.shape)
result = np.matmul(Softmax_Attention_Matrix, self.V)
print('softmax result multiplied by V: \n', result)
return result
def _backprop(self):
# do smth to update W_mat
pass
多头注意力层
论文将多头注意力定义为在平行中应用此机制ℎ次,每个都有自己的W^Q、W^K和W^V矩阵。在过程结束时,我们有ℎ个自注意力矩阵,称为头部:
其中Q_i、K_i、V_i由它们各自的权重矩阵W_i^Q、W_i^K和W_i^V相乘定义。在我们的示例中,我们已经计算了一个单头注意力:
让我们考虑第二个头矩阵,经过我们在这里进行的所有相同计算后,产生以下矩阵:
一旦我们有了单头注意力矩阵,我们可以将多头注意力定义为所有头部i的串联,再乘以一个新的可学习矩阵W_0:
其中W_0(在我们的例子中,是一个8×4矩阵)最初是随机初始化的:
从而得到我们的多头注意力:
最后,结果添加到初始向量X(一个称为残差连接的操作):
残差连接防止模型陷入渐变消失/激增的问题(再次)。其主要思想是当我们将原始X向量添加到矩阵乘法的结果时,每个最终向量的范数被调整为与原始向量的数量级相同。
通过这个过程,我们将3个4维向量(X的)映射到另一组3个4维向量(更新的V)。那么,收益是什么呢?答案是现在我们有3个向量,以某种方式编码(随着训练的进行而编码得到更好)句子中出现的单词之间的注意力/语义关系,与最初由简单的one-hot编码算法写下的3个初始向量形成对比。也就是说,现在我们有了这些向量的embedding,以更精细的方式考虑它们在上下文中出现的情况。让我们将多头注意力层实现为一个新类:
class Multi_Head_Attention:
def __init__(self, n_heads, d_model, X):
self.d_model = d_model
self.n_heads = n_heads
self.d_concat = self.d_model*self.n_heads # 4*8
self.W_0 = np.random.uniform(-1, 1, size=(self.d_concat, self.d_model))
# print('W_0 shape : ', self.W_0.shape)
self.heads = []
self.heads_results = []
i = 0
while i < self.n_heads:
self.heads.append(One_Head_Attention(self.d_model, X))
i += 1
def print_W_0(self):
print('W_0 : \n', self.W_0)
def print_QKV_each_head(self):
i = 0
while i < self.n_heads:
print(f'Head {i}: \n')
self.heads[i].print_QKV()
i += 1
def print_W_matrices_each_head(self):
i = 0
while i < self.n_heads:
print(f'Head {i}: \n')
self.heads[i].W_mat.print_W_matrices()
i += 1
def compute(self):
for head in self.heads:
self.heads_results.append(head.compute_1_head_attention())
# print('head: ', self.heads_results[-1].shape)
multi_head_results = np.concatenate(self.heads_results, axis=1)
# print('multi_head_results shape = ', multi_head_results.shape)
V_updated = np.matmul(multi_head_results, self.W_0)
return V_updated
def back_propagate(self):
# backpropagate W_0
# call _backprop for each head
pass
位置编码和全连接前向网络
我们已经涵盖了我们认为是论文“Attention is all you need”关于编码器部分的核心内容。有两个重要的部分我们留到这一节讨论。首先是在编码器堆栈的开头呈现的,即位置编码过程。
为了简单起见,我们将向注意机制输入向量的方式没有考虑句子中单词的顺序。这确实是一个重大缺点。显然,单词的顺序是它们语义价值的关键元素,因此必须存在于embedding中。在论文中,作者通过使用正弦和余弦函数来解决这个问题。它们用于在embedding向量的每个分量中编码顺序。对于出现在句子中的第i个位置的单词,其每个j-th分量与位置编码关联如下:
由于PE_i是与embedding向量相同大小的向量,它会添加到它们中以包含单词在句子中所占的位置信息:
使用这种构建的一个巨大优势在于我们在不需要任何其他空间的情况下包含了新信息。另一个优势是该信息分布在整个向量上,因此通过层中发生的许多矩阵乘法与其他向量的所有其他组件进行通信。现在准备实现我们的位置编码层:
class Positional_Encoding:
def __init__(self, X):
self.PE_shape = X.shape
self.PE = np.empty(self.PE_shape)
self.d_model = self.PE_shape[1]
def compute(self, X):
for i in range(self.PE_shape[0]):
for j in range(self.PE_shape[1]):
self.PE[i,2*j] = math.sin(i/(10000**(2*j/self.d_model)))
self.PE[i,2*j+1] = math.cos(i/(10000**(2*j/self.d_model)))
# this way we are assuming that the vectors are ordered stacked in X
return X + self.PE
最后,在编码器的末尾,有一个简单的由2层组成的全连接前向网络。虽然这不是论文中的创新部分,但通过ReLU激活函数发挥了添加非线性的重要作用,从而捕捉其他语义关联[2]。让我们实现它们:
class FeedForward(nn.Module):
def __init__(self, d_model, d_ff=2048, dropout=0.1):
super(FeedForward, self).__init__()
self.linear1 = nn.Linear(d_model, d_ff)
self.dropout = nn.Dropout(dropout)
self.linear2 = nn.Linear(d_ff, d_model)
def forward(self, x):
x = F.relu(self.linear1(x))
x = self.dropout(x)
x = self.linear2(x)
return x
结论
我们从探讨NLP中embedding的概念开始,解释了单词及其语义含义如何被转化为AI模型可以处理的数字形式。然后深入研究了Transformer架构,从Single-Head Attention层开始,并解释了在该框架中Queries、Keys和Values的作用。
接着我们讨论了Attention Scores,它们代表了什么,以及如何对它们进行归一化以解决梯度消失和激增的问题。在引导理解单头注意力层的工作方式之后,我们经历了创建多头注意力机制的过程。它使模型能够同时处理和整合输入数据的多个视角。
作为最后的组件,我们涵盖了位置编码和简单的全连接前向网络。第一个允许我们保留单词的顺序,这对理解句子的上下文含义至关重要。第二个通过激活函数起到添加非线性的重要作用。
· END ·
HAPPY LIFE
本文仅供学习交流使用,如有侵权请联系作者删除