本文旨在通过最基础数学的方式(仅假设你掌握了加法和乘法),从零开始讲解大语言模型(LLM)的工作原理。
我们将从用纸和笔构建一个简单的生成式 AI 开始,逐步了解现代 LLM 和 Transformer 架构的核心概念。文中将摒弃机器学习中的复杂术语,将一切以简单数字的方式呈现,同时指出专业术语以便大家更深入学习。
本文包含以下内容:
- 构建一个简单的神经网络
- 如何训练这些模型?
- 这些模型是如何生成语言的?
- 为什么 LLM 表现如此优异?
- 嵌入(Embeddings)
- 子词分词器(Sub-word Tokenizers)
- 自注意力机制(Self-Attention)
- Softmax 函数
- 残差连接(Residual Connections)
- 层归一化(Layer Normalization)
- Dropout(丢弃法)
- 多头注意力机制(Multi-head Attention)
- 位置编码与嵌入(Positional Encoding and Embedding)
- GPT 架构
- Transformer 架构
- 附录
(文章内容较多,建议先收藏)
让我们从基础开始
神经网络仅能接受数字输入,并输出其他数字。关键在于如何将输入转换为数字、解读输出以实现目标,并设计出能够根据输入生成期望输出的神经网络。以下内容将逐步引导你从加法和乘法的基本概念,直至构建像 Llama 3.1 这样的高级模型。
构建一个简单的神经网络
假设我们想构建一个能够对物体进行分类的简单神经网络。
已知数据
- 物体属性:主色调(RGB 值)和体积(毫升)
- 分类目标:将物体分类为“叶子”或“花朵”
示例数据如下:
属性 | 主色调 (RGB) | 体积(毫升) | 分类 |
叶子 | (32, 107, 56) | 11.2 | 叶子 |
向日葵 | (255, 235, 59) | 45.3 | 花朵 |
构建神经网络的步骤:
- 定义输入/输出解释方式:
我们的输入已经是数字,因此可以直接传递到网络中。输出目标是“叶子”和“花朵”,但神经网络无法直接输出类别名称。因此,我们可以选择以下两种方案:
- 输出一个数值:正数表示“叶子”,负数表示“花朵”。
- 输出两个数值:第一个数值表示“叶子”的可能性,第二个数值表示“花朵”的可能性,较大的数值对应的类别为预测结果。
设计神经网络:
在这里我们采用第二种方案,因为它可以更好地扩展到我们接下来要讨论的其他场景。我们的网络包括以下层:
- 输入层:包含 4 个神经元,对应 RGB 和体积数据。
- 中间层:包含 3 个神经元。
- 输出层:包含 2 个神经元,对应“叶子”和“花朵”。
计算示例:
假设权重如下:
- 输入层到中间层:
蓝色连接:32 × 0.10 + 107 × -0.29 + 56 × -0.07 + 11.2 × 0.46 = -26.6
图片来自作者
术语解释:
- 神经元/节点(Neurons/Nodes): 圈内的数值。
- 权重(Weights): 连接线上标注的数值。
- 层(Layers): 神经元的集合,例如输入层包含 4 个神经元。
为了计算这个网络的预测/输出(称为“前向传递”),我们从左边开始。输入层的神经元数据已经可用。要向前移动到下一层,你需要将圆圈中的数字与对应神经元配对的权重相乘,然后将它们加起来。上面展示了蓝色和橙色圆圈的运算。运行整个网络时,我们看到输出层的第一个数字较大,因此我们将其解释为“网络将这些(RGB,Vol)值分类为叶子”。一个训练有素的网络可以根据不同的(RGB,Vol)输入值正确分类对象。
这个模型并不知道什么是叶子或花,也不知道(RGB,Vol)是什么。它的任务是接收恰好 4 个数字并输出恰好 2 个数字。我们解释这些数字的方式是:4 个输入数字是(RGB,Vol),我们也决定查看输出数字并推断,如果第一个数字较大,则它是叶子,依此类推。最终,我们决定选择合适的权重,使得模型能处理我们的输入数字,并给出正确的 2 个数字,以便得到我们想要的解释。
一个有趣的副作用是,你可以使用相同的网络,不是输入 RGB 和体积(Vol),而是输入其他 4 个数字,如云量、湿度等,然后将这两个输出数字解释为“一个小时后会晴天”或“一个小时后会下雨”。只要你调整好权重,你可以让这个相同的网络同时完成两项任务——分类叶子/花,并预测一小时后的天气!网络给出的只是两个数字,是否将它们解释为分类、预测或其他内容完全取决于你。
简化时忽略的内容(无需理解这些内容,不会影响理解):
激活层: 这个网络缺少的一个关键部分是“激活层”。这是一个专业术语,意思是我们会对每个圆圈中的数字应用一个非线性函数(RELU 是一个常见的函数,它的做法是,如果数字为负数,就将它设为 0,若为正数,则保持不变)。所以在上面的例子中,我们会将中间层的两个数字(-26.6 和 -47.1)替换为 0,然后再继续向下一层传递。当然,重新训练权重后,网络才会再次有用。没有激活层时,所有的加法和乘法都可以简化为一个单一的层。在我们的例子中,你可以将绿色圆圈表示为 RGB 的加权和,使用某些权重,你就不再需要中间层了。它可能会是类似这样的公式:(0.10 * -0.17 + 0.12 * 0.39 - 0.36 * 0.1)* R + (-0.29 * -0.17 - 0.05 * 0.39 - 0.21 * 0.1) * G ……等等。如果这里存在非线性操作,这通常是不可行的。非线性操作帮助网络处理更复杂的情况。
偏差(Bias): 网络通常还会包含一个与每个节点相关的额外数字,这个数字就是“偏差”。它被简单地加到乘积中,以计算节点的值。所以如果上面蓝色节点的偏差为 0.25,那么该节点的值将是:(32 * 0.10)+(107 * -0.29)+(56 * -0.07)+(11.2 * 0.46)+ 0.25 = -26.35。通常,模型中所有这些数字被称为“参数”,这些数字并不是神经元/节点本身的值。
Softmax: 我们通常不会直接解释输出层的数字,正如我们在模型中所做的那样。我们将这些数字转化为概率(即使所有数字都是正数,并且总和为 1)。如果输出层中的所有数字已经是正数,你可以通过将每个数字除以输出层所有数字的和来实现这一点。然而,“softmax” 函数通常用于处理正负数字的情况。
如何训练这些模型?
在上面的例子中,我们魔法般地得到了允许将数据输入到模型中并得到良好输出的权重。但这些权重是怎么确定的呢?设置这些权重(或“参数”)的过程叫做“训练模型”,而我们需要一些训练数据来训练这个模型。
假设我们有一些数据,其中包含输入值,并且我们已经知道每个输入对应的是叶子还是花,这就是我们的“训练数据”,我们有每组(R,G,B,Vol)数字对应的叶子/花标签,这就是“带标签的数据”。
训练过程是这样的:
- 初始化随机数: 即将每个参数/权重设置为一个随机数。
- 输入数据: 假设我们输入的数据对应的是叶子(R=32, G=107, B=56, Vol=11.2)。假设我们希望输出层对应叶子的数字更大,我们希望叶子的数字是 0.8,花的数字是 0.2(如上所示,这些是用来演示训练的数字,实际上我们不希望 0.8 和 0.2,在实际应用中,这应该是概率值,即叶子为 1,花为 0)。
- 计算损失: 我们知道我们想要的输出层数字,和我们从随机初始化的权重得到的输出数字(这些数字与我们期望的不一样)。所以,对于输出层的所有神经元,我们计算我们期望的数字和实际得到的数字之间的差异,然后将这些差异加起来。例如,如果输出层的两个神经元分别为 0.6 和 0.4,那么差异为:(0.8 - 0.6)= 0.2 和 (0.2 - 0.4)= -0.2,最终的总差异为 0.4(忽略负号再加起来)。我们可以称这个差异为“损失” 。理想情况下,我们希望损失接近于零,即我们希望“最小化损失”。
- 调整参数: 一旦得到了损失,我们可以稍微调整每个参数,看看增加或减少它是否能减小损失。这就是该参数的“梯度”。然后,我们可以朝着减少损失的方向,略微调整每个参数(即朝梯度的反方向调整)。一旦所有参数都稍微调整过,损失应该会变小。
- 重复过程: 不断重复上述过程,你会减少损失,最终得到一组“训练过的”权重/参数。这个过程被称为“梯度下降”。
几个注意事项:
- 你通常会有多个训练样本,因此当你为了某个样本减小损失时,可能会导致另一个样本的损失变大。为了解决这个问题,通常会将损失定义为所有样本损失的平均值,然后对平均损失计算梯度。这种方法可以减少整个训练数据集的平均损失。每次这样的迭代称为一个“轮次”(epoch)。你可以不断重复这个过程,从而找到能够减少平均损失的权重。
- 计算每个权重的梯度时,实际上我们并不需要“调整权重”来计算梯度——我们可以直接通过公式推导出来(例如,如果最后一步权重是 0.17,并且该神经元的值为正数,我们希望输出更大,那么我们可以看到将权重增加到 0.18 会有所帮助)。
在实践中,训练深度网络是一个困难且复杂的过程,因为梯度很容易在训练过程中失控,可能会变为零或无穷大(这分别被称为“梯度消失”和“梯度爆炸”问题)。我们在这里讨论的简单损失定义是完全有效的,但在实际应用中较少使用,因为有更适合特定目的的函数形式。随着现代模型包含数十亿的参数,训练模型需要巨大的计算资源,这本身也带来了一些问题(内存限制、并行化等)。
这些模型是如何生成语言的?
记住,神经网络接收数字,根据训练好的参数做一些数学运算,然后输出数字。一切的关键在于解释和训练参数(即将它们设置为某些数字)。如果我们可以将这两个数字解释为“叶子/花”或“一小时后晴天或下雨”,我们也可以将它们解释为“下一个字符”。
但是,英语中有不止 2 个字母,所以我们必须扩展输出层的神经元数量,假设是 26 个字母(我们还可以加入一些符号,如空格、句号等)。每个神经元可以对应一个字符,我们查看输出层的(大约 26 个)神经元,然后选择对应最大值的神经元,输出这个字符。现在,我们有了一个可以接收输入并输出字符的网络。
那么,如果我们用这些字符替换网络中的输入:“Humpty Dumpt”,并要求它输出一个字符,并将其解释为“网络对我们刚输入的序列的下一个字符建议”。我们可能可以把权重调整得足够好,以便它输出 “y”,从而完成 “Humpty Dumpty”。但是有一个问题,如何将这些字符输入到网络中呢?我们的网络只接受数字!!
一个简单的解决方案是为每个字符分配一个数字。假设 a=1,b=2,依此类推。现在,我们可以输入“humpty dumpt”,并训练它输出“y”。我们的网络大概看起来像这样:
图片来自作者
既然我们现在可以通过给网络输入一串字符来预测下一个字符,我们就可以利用这一点构建一个完整的句子。例如,一旦我们预测出 “y”,我们就可以将这个 “y” 追加到现有的字符列表中,然后再次输入到网络中,要求它预测下一个字符。如果网络训练得足够好,它应该会输出一个空格,依此类推。最终,我们应该能够递归地生成 “Humpty Dumpty sat on a wall”。这就实现了生成式 AI(Generative AI)。更重要的是,我们现在有了一个能够生成语言的网络!当然,实际上不会直接使用随机分配的数字,后面会看到一些更合理的方案。如果你等不及了,可以参考附录中的“独热编码”(One-hot encoding)部分。
敏锐的读者会注意到,我们实际上不能将“Humpty Dumpty”输入到网络中,因为如图所示,输入层只有 12 个神经元,每个字符(包括空格)对应一个神经元。那么,如何输入下一个字符 “y”呢?如果我们添加第 13 个神经元,就需要修改整个网络,这显然不可行。解决方法很简单,我们将 “h” 移出,改为输入最近的 12 个字符。所以,我们将输入 “umpty dumpty”,网络将预测一个空格。接着,我们输入 “mpty dumpty ”,它将预测出字母“s”,依此类推。这个过程大致如下:
图片来自作者
我们在最后一行中丢失了很多信息,给模型输入的只是“ sat on the wal”。那么今天最新最先进的网络是如何处理这个问题的呢?实际上,它们做的基本就是这个。我们能够输入到网络中的信息长度是固定的(由输入层的大小决定)。这个长度叫做“上下文长度”——即为网络提供的上下文,用来做未来的预测。现代的网络可以拥有非常大的上下文长度(几个千词),这的确有帮助。虽然有一些方法可以输入无限长的序列,但这些方法的表现虽然令人印象深刻,但目前已经被其他具有较大(但固定)上下文长度的模型所超越。
细心的读者会注意到,我们对同一个字母的输入和输出有不同的解释!例如,当输入 “h” 时,我们只是用数字8来表示它,但在输出层,我们并不是要求模型输出一个数字(比如 “h” 对应 8,“i” 对应 9,依此类推),而是要求模型输出 26 个数字,然后我们查看哪个数字最大,如果第 8 个数字最大,我们就将输出解释为 “h”。为什么不在两端使用相同、一致的解释方式呢?其实是可以的,只是对于语言来说,允许自己选择不同的解释方式,可以更好地构建模型。而且恰巧目前已知的最有效的输入和输出的解释方式是不同的。实际上,我们在这个模型中输入数字的方式并不是最好的方法,我们稍后会讨论一些更好的方法。
为什么 LLM 表现如此优异?
逐个字符生成 “Humpty Dumpty sat on a wall” 距离现代大语言模型(LLM)能够做的事情还有很大差距。从我们刚才讨论的简单生成式 AI 到类似人类的聊天机器人之间,有许多不同的创新和进步。让我们逐一看看这些差异:
嵌入(Embeddings)
记住我们之前说过,当前给模型输入字符的方式并不是最好的。我们只是随便为每个字符选择了一个数字。那么,是否有更好的数字可以分配,以便我们能够训练出更好的网络呢?如何找到这些更好的数字呢?这儿有一个巧妙的技巧:
当我们训练上面的模型时,我们的做法是通过调整权重,看哪种方式能最终得到更小的损失。然后,我们不断地、递归地调整权重。在每一次迭代中,我们会:
- 输入数据。
- 计算输出层的结果。
- 将输出与我们理想中的输出进行比较,并计算平均损失。
- 调整权重并重新开始。
在这个过程中,输入是固定的。当输入是(RGB,Vol)时,这样做是合理的。但现在我们输入的字符如 a、b、c 等是我们随意选择的。那么,如果我们每次迭代时,不仅调整权重,还调整输入的数字,看看通过使用不同的数字来表示 “a” 等字符是否能减小损失呢?通过这种方法,我们确实能够减少损失并使模型变得更好(这就是我们在设计中所调整的 a 的输入方向)。基本上,我们对输入的数字表示应用梯度下降,不仅仅是对权重,因为这些输入的数字本身就是任意选择的。这个过程叫做“嵌入”(embedding)。它是将输入映射到数字的过程,并且正如你刚才看到的,它需要经过训练。训练嵌入的过程与训练参数的过程非常相似。嵌入的一个大优点是,一旦你训练好了一个嵌入,你可以将它应用到其他模型中。如果你愿意,可以在其他模型中重复使用相同的嵌入来表示一个特定的标记/字符/词。
我们刚才讨论的嵌入是每个字符对应一个数字。然而,实际上,嵌入通常包含多个数字。这是因为,用一个数字很难捕捉到概念的丰富性。如果我们看看之前的叶子和花朵的例子,我们每个物体都有四个数字(输入层的大小)。这四个数字表示了物体的不同属性,模型能够有效地使用它们来猜测物体的类型。如果我们只有一个数字,比如颜色的红色通道,那么模型可能会变得很难处理。我们现在要处理的是人类语言——我们显然需要多个数字来表示。
所以,除了用一个数字来表示每个字符,我们是否可以用多个数字来表示它,以捕捉更丰富的信息呢?让我们为每个字符分配一组数字。我们将这种有序的数字集合称为“向量”(有序指的是每个数字都有一个位置,如果我们交换两个数字的位置,得到的就是一个不同的向量。就像我们在叶子/花朵数据中的情况一样,如果我们交换了叶子的 R 和 G 的数字,我们会得到不同的颜色,它将不再是同一个向量)。向量的长度就是它包含的数字的数量。我们为每个字符分配一个向量。这里有两个问题:
- 如果我们为每个字符分配一个向量而不是一个数字,那么我们该如何将 “humpty dumpt” 输入到网络中呢?答案很简单。假设我们为每个字符分配一个包含 10 个数字的向量。那样的话,输入层就不再只有 12 个神经元了,而是需要 120 个神经元,因为 “humpty dumpt” 中的每个字符都对应 10 个数字的输入。现在,我们只需将这些神经元依次排列好,模型就能正常工作。
- 那么我们如何找到这些向量呢?幸运的是,我们刚刚学会了如何训练嵌入数字。训练嵌入向量与此并没有区别。现在你有 120 个输入而不是 12 个,但你做的就是调整这些输入的数字,看看如何最小化损失。然后,你就能取出前 10 个数字作为 “h” 对应的向量,依此类推。
当然,所有的嵌入向量必须具有相同的长度,否则我们就无法将所有字符组合输入到网络中。例如,“humpty dumpt” 和下一次的 “umpty dumpty”——在这两种情况下,我们都需要将 12 个字符输入到网络中,如果每个字符的向量长度不是 10,那么我们就无法可靠地将这些字符输入到长度为 120 的输入层中。让我们来可视化一下这些嵌入向量:
图片来自作者
我们将一组大小相同的有序向量称为矩阵。上面的矩阵称为嵌入矩阵。你告诉它一个对应字母的列号,然后查看矩阵中的那一列,就能得到用来表示该字母的向量。这个方法可以更广泛地应用于嵌入任何任意集合的事物——你只需要确保这个矩阵的列数与事物的数量相匹配。
子词分词器
到目前为止,我们一直将字符作为语言的基本构建块来处理,但这有其局限性。神经网络的权重必须进行大量的计算,它们需要理解某些字符序列(即单词)相互出现并与其他单词相邻的情况。假设我们直接为单词分配嵌入,并让网络预测下一个单词。网络本质上只理解数字,所以我们可以为 “humpty”、 “dumpty”、 “sat”、 “on” 等单词分别分配一个10长度的向量,然后输入两个单词,网络就可以给出下一个单词。这里,“Token” 是指我们嵌入并输入给模型的单个单元。我们之前的模型使用字符作为 Token,现在我们提议使用整个单词作为 Token(当然,如果你愿意,也可以使用整个句子或短语作为 Token)。
使用单词分词会对我们的模型产生深远的影响。英语中有超过 18 万个单词。如果我们继续使用输出层每个可能的输出都对应一个神经元的解释方案,那么我们就需要数十万个神经元,而不是之前的 26 个左右。考虑到现代网络隐藏层所需的规模,这个问题变得不那么紧迫。然而,值得注意的是,由于我们将每个单词视为独立的单元,并且我们一开始为每个单词分配的是随机的嵌入向量——那么非常相似的单词(例如“cat”和“cats”)最开始是没有任何关系的。我们期望这两个单词的嵌入应该彼此接近——这也是模型将会学到的。但我们是否能利用这种明显的相似性,先行一步简化问题呢?
答案是肯定的。如今语言模型中最常用的嵌入方案之一就是将单词拆分为子词进行嵌入。以 “cat” 为例,我们可以将 “cats” 拆分为两个 Token:“cat” 和 “s”。现在,模型更容易理解“s”后面跟随其他熟悉的单词,依此类推。这种方法还减少了我们所需的 Token 数量(例如,SentencePiece 是一种常见的分词器,其词汇大小通常在几万个,而不是英语中的几十万个单词)。分词器是一种将输入文本(例如 “Humpty Dumpt”)拆分为多个 Token,并给出相应数字的工具,这些数字将用于在嵌入矩阵中查找对应的嵌入向量。例如,在 “humpty dumpty” 中,如果我们使用字符级分词器并且按照上面的矩阵方式排列嵌入矩阵,那么分词器会首先将 “humpty dumpt” 拆分为字符 [‘h’, ‘u’, …, ‘t’],然后返回数字 [8, 21, …, 20],因为你需要查找嵌入矩阵中的第 8 列,得到 'h' 的嵌入向量(嵌入向量是你输入给模型的内容,而不是数字 8,这与之前不同)。矩阵中列的排列是完全无关紧要的,我们可以将任意列分配给 'h',只要每次输入 'h'时查找的向量相同,就没有问题。分词器实际上给我们的是一个任意(但固定的)数字,目的是便于查找。它们的主要任务是将句子拆分为 Token。
使用嵌入和子词分词后,模型可能会长成如下所示:
图片来自作者
接下来的几节内容将涉及语言建模中的一些最新进展,这些进展使得大语言模型(LLM)变得如此强大。然而,要理解这些内容,你需要掌握以下几个基本的数学概念:
- 矩阵与矩阵乘法
- 数学中函数的基本概念
- 指数运算(例如,a³ = a * a * a)
- 样本均值、方差和标准差
我已经在附录中添加了这些概念的总结。
自注意力(Self-Attention)
到目前为止,我们只见过一种简单的神经网络结构(称为前馈网络),这种网络由若干层组成,并且每一层都与下一层完全连接(即,连续层之间的每两个神经元之间都有连接)。然而,正如你所想的那样,我们并不需要局限于这种结构。我们完全可以删除或增加其他连接,甚至构建更复杂的结构。接下来,我们将探索一个特别重要的结构:自注意力。
如果我们观察人类语言的结构,可以发现,我们想要预测的下一个单词依赖于前面所有的单词。然而,它们之间的依赖关系可能会有所不同,有些单词的影响可能比其他单词更大。例如,如果我们试图预测这句话中的下一个单词:“Damian had a secret child, a girl, and he had written in his will that all his belongings, along with the magical orb, will belong to ____”。此处的空格可能填入 “her”或 “him”,而这个选择具体取决于句子中一个较早的单词:girl 或 boy。
好消息是,我们的简单前馈模型已经可以连接上下文中的所有单词,因此它能够为重要的单词学习适当的权重。但是问题在于,我们通过前馈层连接到模型中特定位置的权重是固定的(对于每个位置都是一样的)。如果重要的单词总是在相同的位置,模型就能适当地学习到这些权重,我们就可以正常工作。然而,下一次预测所依赖的相关单词,可能出现在任何位置。我们可以将上面的句子重述一下,并在猜测 “her” 或 “his” 时,无论 “girl” 还是 “boy” 出现在句子的哪个位置,它都是一个非常重要的单词。因此,我们需要的权重不仅取决于位置,还必须根据位置的内容来决定。我们该如何实现这一点呢?
自注意力的实现
自注意力所做的事情类似于将每个单词的嵌入向量加起来,但它并不是直接将它们加在一起,而是为每个向量应用一些权重。因此,如果 “humpty”、 “dumpty”、 “sat” 的嵌入向量分别为 x1、x2、x3,那么它会为每个向量乘以一个权重(一个数字),然后再将它们加起来。假设最终的输出为:output=0.5⋅x1+0.25⋅x2+0.25⋅x3 output=u1⋅x1+u2⋅x2+u3⋅x3 这里的输出就是自注意力的结果。如果我们将权重表示为 u1, u2, u3 ,那么我们可以写出:output=u1⋅x1+u2⋅x2+u3⋅x3 。那么,如何找到这些权重 u1, u2, u3 呢?
权重的依赖关系
理想情况下,我们希望这些权重取决于我们所加的向量——正如我们看到的那样,某些向量比其他向量更重要。但是,重要性取决于谁呢?答案是,取决于我们即将预测的单词。因此,我们也希望权重取决于我们即将预测的单词。现在问题来了,我们在预测之前并不知道下一个单词是什么。那么,自注意力如何解决这个问题呢?
自注意力使用的是紧接着我们要预测的单词之前的那个单词,即句子中的最后一个已知单词(虽然我不清楚为什么选择这样做,而不是其他方式,但深度学习中的很多事情都是通过反复试验确定的,我猜这个方法有效)。
如何计算权重
我们希望这些向量的权重依赖于我们正在聚合的单词以及我们即将预测的单词前一个单词。基本上,我们希望权重的计算公式为:u1=F(x1,x3) ,其中 x1 是我们将加权的单词,而 x3 是我们已知的最后一个单词(假设我们只有3个单词)。要实现这一点,一种直接的方法是,为 x1 和 x3 分别构建两个向量(我们可以分别称之为 k1 和 q3 ),然后简单地计算它们的点积。这样,我们就能得到一个依赖于 x1 和 x3 的数字。如何得到这些向量 k1 和 q3 呢?我们为每个单词构建一个小的单层神经网络,将嵌入向量 x1 映射到 k1 ,将 x3 映射到 q3 ,以此类推。使用矩阵表示法,我们实际上得到权重矩阵 Wk 和 Wq ,使得:k1=Wk⋅x1andq3=Wq⋅x3。现在,我们可以通过计算 k1 和 q3 的点积来得到标量值:u1=F(x1,x3)=Wk⋅x1⋅Wq⋅x3
添加值向量
自注意力中的另一个重要步骤是,我们并不是直接对嵌入向量进行加权求和,而是对这些嵌入向量的某种“值”进行加权求和,而这个“值”是通过另一个小的单层神经网络得到的。与 k1 和 q1 类似,我们现在也为每个单词生成一个值向量 v1 ,它是通过矩阵 Wv 得到的:v1=Wv⋅x1 ,然后,我们对这些值进行加权求和。最终,整个自注意力过程如下所示(假设我们只有 3 个单词,正在预测第四个单词):
加号代表向量的简单相加,这意味着它们必须具有相同的长度。这里还有最后一个未显示的修改,就是标量 u1, u2, u3 等等不一定会加起来等于1。如果我们需要它们作为权重,我们应该让它们相加等于1。所以,我们将在这里应用一个熟悉的技巧,使用 softmax 函数。
这就是自注意力(Self-Attention)。此外,还有 交叉注意力(Cross-Attention),在这种情况下,查询向量 q3 来自于最后一个单词,但键向量 k 和值向量 v 则可以来自完全不同的句子。例如,在机器翻译任务中,这种机制就非常有用。现在,我们已经知道了什么是注意力机制。
整个过程现在可以封装起来,称之为“自注意力块”(Self-Attention Block)。基本上,这个自注意力块接收嵌入向量并输出一个用户选择长度的单一输出向量。这个模块有三个参数:Wk , Wq , 和 Wv ,而且它不需要更复杂的东西。机器学习文献中有很多这样的块,通常它们在图示中表示为一个框,并标明其名称。比如,图示可能是这样:
图片来自作者
在自注意力机制中,你会注意到到目前为止,位置似乎并不重要。我们在整个过程中使用相同的 W 矩阵,所以交换 “Humpty” 和 “Dumpty” 并不会真正产生任何影响 —— 所有数字最终都会是相同的。这意味着,虽然注意力机制可以识别出需要关注的部分,但这并不依赖于单词的位置。然而,我们知道在英语中单词的位置是很重要的,给模型提供一些单词位置的感知可能会改善模型的表现。
因此,当使用注意力机制时,我们并不会直接将嵌入向量输入到自注意力块中。我们稍后会看到如何在将嵌入向量输入到注意力块之前,加入“位置编码”(Positional Encoding)。
关于已了解的读者:对于那些不止一次接触自注意力的读者,你会注意到我们没有提到任何 K 和 Q 矩阵,也没有应用掩码等。这是因为这些都是实现细节,通常是在训练模型时出现的。数据批次会被输入,模型会同时训练来预测从 “humpty” 到 “dumpty”、从 “humpty dumpty” 到 “sat” 等等。这是为了提高效率,并不会影响模型的解释或输出,我们在此省略了训练效率的技巧。
Softmax
我们在第一部分中简单提到过 softmax 函数。现在来看一下 softmax 试图解决的问题:在我们的输出解释中,我们有与选择选项数量相同的神经元,我们希望网络从中选择一个。然后,我们说将网络的选择解释为最大值神经元。接着,我们说我们要计算损失,作为网络输出值和我们理想值之间的差异。但是,这个理想值到底是什么呢?在我们之前的叶子/花朵例子中,我们将理想值设置为 0.8。那么为什么是 0.8,而不是 5、10 或者 1000 万呢?理想情况下,理想值应该是越大越好!我们期望它接近无穷大!但这会让问题变得不可解 —— 所有的损失都会是无穷大,而我们通过调整参数来最小化损失的计划(记得“梯度下降”)也就失败了。那么我们该如何处理这个问题呢?
我们可以做的一个简单的事情是对我们想要的值进行限制。比如说,限制在 0 到 1 之间?这样所有的损失就都是有限的,但是我们现在面临一个问题,那就是如果网络输出超过这个范围怎么办?比如,在 “leaf, flower” 的情况下,网络输出为 (5, 1) 和 (0, 1)。第一个案例是正确的选择,但损失却更大!好吧,现在我们需要一种方法来将最后一层的输出转换到(0, 1)区间,同时保持顺序。我们可以使用任何函数(在数学中,函数指的是一个数字映射到另一个数字 —— 输入一个数字,输出另一个数字 —— 这是基于规则的)来完成这个任务。一个可能的选项是 logistic 函数(如下图所示),它将所有数字映射到 (0, 1) 区间,并且保持输出顺序:
图片来自作者
现在,我们得到了每个神经元的值,范围在 0 到 1 之间,并且可以通过将正确的神经元设置为 1,其他神经元设置为 0,来计算损失并与网络的输出进行比较。这是可行的,但我们能做得更好吗?
回到我们之前的 “Humpty Dumpty” 例子,假设我们正在逐个字符地生成 “dumpty”,并且当我们预测 “dumpty” 中的 “m” 时,模型犯了个错误。模型没有把 “m” 预测为最高值,而是将 “u” 作为最高值,尽管 “m” 是接近的第二高值。
我们可以继续预测 “duu” 并尝试预测下一个字符,以此类推,但由于从 “humpty duu..” 继续的选项并不多,模型的信心会较低。另一方面,“m” 是第二高的选项,因此我们也可以尝试使用 “m”,继续预测接下来的几个字符,看看会发生什么。也许它会给出一个更合适的整体单词?
所以我们在这里讨论的并不仅仅是盲目地选择最大值,而是尝试一些其他的选项。怎么做比较好呢?我们可以为每个选项分配一个概率——比如选取排名第一的选项的概率为 50%,第二的为 25%,以此类推。这样做是合理的。但是,也许我们希望概率能够依赖于模型的实际预测。如果模型预测 “m” 和 “u” 之间的值非常接近(相对于其他值),那么让它们的选择概率为 50% 和 50% 可能是个不错的选择。
因此,我们需要一个好的规则来将这些数字转换成概率,这就是 softmax 函数的作用。它是上面提到的 logistic 函数的一个推广,但它有更多的功能。如果你给它 10 个任意的数字,它会将这些数字转换成 10 个输出,每个输出都在 0 到 1 之间,最重要的是,这 10 个值的和为 1,这样我们就可以将它们解释为概率。在几乎每个语言模型的最后一层,你都会看到 softmax 函数。
残差连接
随着章节的进展,我们逐渐改变了对网络的可视化方式。现在我们使用框/块来表示某些概念。这种符号表示法在表示 残差连接(Residual Connections)这一特别有用的概念时非常有用。让我们来看一下结合了残差连接的自注意力块:
残差连接,图片来自作者
请注意,我们将“输入”和“输出”表示为框,以简化说明,但这些本质上仍然只是神经元/数字的集合,如上所示。
那么这里发生了什么呢?我们基本上是将自注意力块的输出传递到下一个块之前,将原始输入加到输出上。首先需要注意的是,这要求自注意力块的输出维度与输入的维度必须相同。这并不是问题,因为正如我们所提到的,自注意力输出的维度是由用户确定的。那么为什么要这样做呢?我们不会深入讨论所有的细节,但关键在于,随着网络变得更深(输入和输出之间的层数增多),训练变得越来越困难。已经证明,残差连接能够帮助解决这些训练上的挑战。
层归一化 (Layer Normalization)
层归一化是一个相对简单的层,它接收输入的数据信号,并通过减去均值并除以标准差来对其进行归一化(可能还会进行一些其他操作,如下文所示)。举个例子,如果我们将层归一化应用于输入层,它会对输入层的所有神经元进行操作,计算出两个统计量:它们的均值和标准差。假设均值为 M,标准差为 D,那么层归一化所做的就是将每个神经元的值替换为 (x - M) / D ,其中 x 表示任何一个神经元的原始值。
那么这有什么帮助呢?它基本上是稳定了输入向量,有助于深度网络的训练。一个担心的问题是,通过归一化输入,我们是否会去除一些有用的信息,这些信息可能对学习目标非常重要?为了解决这个问题,层归一化层引入了一个比例和一个偏差参数。具体来说,对于每个神经元,你会将它乘以一个标量,然后加上一个偏差。这个标量和偏差的值是可以训练的参数。这使得网络可以学习到对预测可能有价值的某些变动。而且,由于这些是唯一的参数,LayerNorm 层的训练参数非常少。整个过程大致如下所示:
层归一化,图片来自作者
缩放和偏差是可训练的参数。你可以看到,层归一化是一种相对简单的操作,其中每个数字仅仅是逐点操作(在初始均值和标准差计算之后)。这让我们想起了激活层(例如 RELU),其关键区别在于这里我们有一些可训练的参数(虽然因为是简单的逐点操作,训练参数比其他层少得多)。
标准差是一个统计量,表示数据的分散程度。例如,如果所有的数值都相同,那么标准差就是零。如果一般情况下每个数值都远离这些数值的均值,那么标准差就会很高。计算标准差的公式如下:对于一组数字 a_1, a_2, a_3, (假设有 N 个数),首先从每个数字中减去均值,然后将每个差值平方。然后将这些平方差加起来,再除以 N 。最后对结果取平方根。
对于已经有一定基础的读者:有经验的机器学习专业人士可能会注意到,这里没有讨论批归一化(batch norm)。事实上,我们在本文中甚至没有介绍批次的概念。大多数情况下,我认为批次是另一种加速训练的技巧,并不直接影响核心概念的理解(除了批归一化这一点,我们在这里不需要讨论)。
Dropout(丢弃法)
Dropout 是一种简单但有效的方法,用来避免模型的过拟合。过拟合是指当你在训练数据上训练模型时,它在该数据集上表现得很好,但对模型未见过的样本的泛化能力差。帮助我们避免过拟合的技术被称为“正则化技术”,而 dropout 就是其中之一。
如果你训练一个模型,它可能会在数据上出错,或者以某种方式对数据过拟合。如果你训练另一个模型,它可能会做出同样的错误,但方式不同。那么,如果你训练多个这样的模型并对它们的输出取平均会怎么样呢?这些模型通常被称为“集成模型”,因为它们通过组合多个模型的输出进行预测,集成模型通常比单个模型表现得更好。
在神经网络中,你也可以做同样的事情。你可以构建多个(稍有不同的)模型,然后将它们的输出结合起来,得到一个更好的模型。然而,这种方法计算开销较大。Dropout 是一种技术,它并不像集成模型那样构建多个模型,但它能够捕捉到集成模型的一些本质。
其概念很简单,通过在训练过程中插入一个 dropout 层,你实际上是在随机删除一些层之间的神经元连接(删除的比例是 dropout 率)。以我们的初始网络为例,如果在输入层和中间层之间插入一个 dropout 层,并设定 50% 的 dropout 率,它看起来会是这样:
图片来自作者
现在,这种方法迫使网络在训练时具有大量冗余。实际上,你是在同时训练多个不同的模型——但它们共享相同的权重。
推理过程:我们可以按照集成模型的相同方法来进行推理。我们可以使用 dropout 进行多次预测,然后将它们的结果结合起来。然而,由于这种方法计算开销较大——并且我们的模型共享相同的权重——为什么不直接使用所有的权重进行预测(也就是一次性使用所有权重,而不是每次使用 50% 的权重)呢?这应该能为我们提供一个类似集成模型的近似效果。
然而,有一个问题:使用 50% 权重训练的模型和使用所有权重的模型在中间神经元的数值上会有很大的不同。我们希望得到的是一种类似集成风格的平均效果。那么,我们该如何实现呢?一个简单的方法是,直接将所有的权重乘以 0.5,因为我们现在使用了两倍的权重。这就是Dropout 在推理时的做法:它将使用整个网络的所有权重,并简单地将这些权重乘以 (1 - p) ,其中 p 是丢弃概率。这种方法作为一种正则化技术,已经证明非常有效。
多头注意力(Multi-head Attention)
这是 Transformer 架构 中的关键模块。我们已经了解了什么是注意力机制。记得注意力模块的输出是由用户决定的,它的长度等于 v 的长度。那么,多头注意力究竟是什么呢?基本上,你会并行运行多个注意力头(它们接受相同的输入)。然后,我们将所有的输出结果拼接起来。它的结构大致如下所示:
图片来自作者
请注意,从 v1 到 v1h1 的箭头表示线性层——每个箭头上都有一个矩阵进行变换,我没有将它们展示出来是为了避免干扰。
在这里发生的事情是,我们为每个注意力头生成相同的 key、query 和 value。但是我们在使用这些 k、q、v值之前,基本上会对它们应用一个线性变换(分别对每个 k、q、v 进行变换,并且对每个头也分别进行变换)。这个额外的层在 自注意力机制 中并不存在。
旁注:对我来说,这种创建多头注意力的方式有点让人惊讶。例如,为什么不为每个头分别创建不同的 Wk、Wq、Wv 矩阵,而是通过增加一个新的层并共享这些权重呢?如果你知道原因,告诉我一下——我真的不太清楚。
位置编码与嵌入(Positional Encoding and Embedding)
我们在 自注意力 部分简要讨论了使用位置编码的动机。那它们究竟是什么呢?尽管图中展示的是位置编码,但使用位置嵌入(positional embedding)要比使用位置编码更常见。因此,我们在这里讨论的是一种常见的 位置嵌入,但附录部分也涉及了原始论文中使用的位置编码。位置嵌入 与其他嵌入没有太大区别,唯一的区别是它不是嵌入词汇,而是嵌入数字 1、2、3 等。因此,这个嵌入是一个与词嵌入矩阵长度相同的矩阵,每一列对应一个数字。实际上,它就是这么简单。
GPT 架构
接下来,我们讨论 GPT 架构。这是大多数 GPT 模型 使用的架构(虽然各个模型之间会有所变化)。如果你已经阅读到这里,这部分应该非常容易理解。使用框图表示,这就是该架构的高级视图:
图片来自作者
到目前为止,除了 “GPT Transformer Block” 之外,其他的模块我们已经讨论得很详细了。这里的加号表示将两个向量相加(也就是说,两个嵌入必须具有相同的大小)。现在让我们看看这个 GPT Transformer Block:
图片来自作者
这基本就是整个架构。之所以称之为 “transformer”,是因为它源自并且属于一种 transformer 架构——我们将在下一节中详细讨论这种架构。这并不会影响理解,因为我们已经详细介绍了图中显示的所有基本模块。接下来,让我们回顾一下到目前为止我们所涵盖的内容,这些内容构成了 GPT 架构 的基础:
- 我们看到了神经网络是如何处理数字并输出其他数字的,它们的权重作为参数是可以训练的。
- 我们可以将这些输入/输出的数字附加上解释,并赋予神经网络现实世界中的意义。
- 我们可以将神经网络串联起来,创建更大的网络,并且我们可以把每个网络称为一个“模块”,用框表示,这样可以简化图示。每个模块依然做的是相同的事情:接受一堆数字并输出另一堆数字。
- 我们学习了许多不同类型的模块,它们各自有不同的作用。
- GPT 就是这些模块的特殊排列,如上所示,并且我们在第1部分中已经讨论了它的解释。
- 随着时间的推移,各家公司在此基础上做了改进,逐步发展出了强大的现代大型语言模型(LLM),但其基本结构保持不变。
现在,这个 GPT Transformer 实际上在原始的 transformer 论文中被称为“解码器”(decoder)。让我们来看一下这个架构。
Transformer 架构
这是最近推动语言模型能力快速提升的关键创新之一。Transformer 不仅提高了预测准确性,而且比之前的模型更容易、更高效(训练上),从而允许更大的模型规模。这也是上面所示的 GPT 架构 所基于的架构。
如果你看一下 GPT 架构,你会发现它非常适合生成序列中的下一个词。它本质上遵循了我们在第1部分讨论的相同逻辑:从几个词开始,然后一个一个地继续生成。但是,如果你想进行翻译呢?如果你有一个德语句子(例如 “Wo wohnst du?” = “你住在哪里?”),你想把它翻译成英语。我们该如何训练模型来做这个呢?
首先,我们需要做的就是找出一种输入德语单词的方式。这意味着我们必须扩展我们的嵌入,使其同时包含德语和英语。那么,假设我们有一个简单的方式来输入信息。为什么不把德语句子直接连接到已经生成的英语句子前面,然后将其作为上下文输入呢?为了让模型更容易理解,我们可以加上一个分隔符。每一步的输入可以长这样:
图片来自作者
这将可行,但仍有改进的空间:
- 如果上下文长度是固定的,有时原始句子会丢失。
- 模型需要学习很多东西——同时处理两种语言,并且要知道
<SEP>
是分隔符 token,它需要在这里开始进行翻译。 - 你在生成每个词时都需要处理整个德语句子,并且句子中的每个位置会有不同的偏移。这意味着同样的内容会有不同的内部表示,模型应该能够处理所有这些差异来完成翻译。
Transformer 最初是为了处理这个任务而创建的,它由“编码器”(encoder)和“解码器”(decoder)两部分组成——这基本上是两个独立的模块。一个模块简单地接受德语句子,并输出一个中间表示(本质上是一些数字)——这就是编码器(encoder)。
第二个模块生成单词(我们到目前为止已经看到了很多这种生成)。唯一的区别是,除了将已经生成的单词喂入解码器外,我们还将编码器输出的德语句子喂入解码器。因此,在生成语言时,它的上下文实际上是到目前为止生成的所有单词,外加德语句子。这一模块就是解码器(decoder)。
每个编码器和解码器都由若干个模块组成,特别是夹在其他层之间的注意力模块(attention block)。让我们看一下论文《Attention is all you need》中的 transformer 架构图,并尝试理解它:
左边的垂直一组块被称为“编码器”(encoder),而右边的一组块则被称为“解码器”(decoder)。让我们逐一回顾并理解一下我们尚未涉及的内容:
如何阅读图示的回顾: 这里的每一个框表示一个块(block),它接受一组神经元作为输入,并输出一组神经元,这些输出可以被下一个块处理或由我们进行解读。箭头表示块的输出流向哪里。正如你所看到的,我们经常会将一个块的输出作为输入,传递给多个其他块。让我们逐个了解这些内容:
前馈网络(Feed Forward): 前馈网络是指不包含循环的网络。我们在第一部分提到的原始网络就是一个前馈网络。实际上,这里的块结构与之前的结构非常相似。它包含两个线性层,每个线性层后面跟着一个 RELU 激活层(请参考第一部分关于 RELU 的解释)以及一个 Dropout 层。请记住,这个前馈网络是对每个位置独立应用的。意味着位置 0 的信息有一个前馈网络,位置1的信息也有一个前馈网络,依此类推……但是,位置x的神经元与位置y的前馈网络没有连接。这一点非常重要,因为如果我们不这样做,网络就可以在训练时“作弊”,通过查看未来的词来影响结果。
跨注意力(Cross-Attention): 你会注意到解码器中有一个多头注意力层(multi-head attention),并且箭头指向编码器。这是怎么回事呢?记得在自注意力(self-attention)和多头注意力(multi-head attention)中,查询(query)、键(key)和值(value)都是来自同一个序列吗?实际上,查询通常是来自序列中的最后一个词。那么,如果我们保留查询,但从一个完全不同的序列中获取键和值呢?这就是这里发生的事情。值和键是来自编码器输出的。数学上没有任何变化,唯一的区别是键和值的输入来源发生了变化。
Nx: 这里的 Nx 表示该块会被链式重复 N 次。基本上,你将块一层一层堆叠起来,并将每个前一层的输入传递到下一层。这样做是为了让神经网络变得更深。从图示来看,可能会对编码器的输出如何传递到解码器产生一些困惑。假设 N=5,那么我们是将每个编码器层的输出传递给相应的解码器层吗?不是的。实际上,你只需要通过编码器一次并完成它。然后,将编码器的输出表示直接传递给所有5个解码器层。
加法与归一化块(Add & Norm): 这个块基本上与下面的块相同(可能是作者为了节省空间而简化了描述)。
图片来自作者
其他内容我们已经讨论过了。现在,你已经完整地理解了 Transformer 架构,从简单的加法和乘法操作开始,逐步构建出了一个自足的完整体系!你现在知道每一行、每个加法、每个框和每个词在构建 Transformer 时的含义。理论上,这些笔记提供了从零开始编码 Transformer 所需的所有知识。事实上,如果你有兴趣,可以参考这个仓库(https://github.com/karpathy/nanoGPT),它实现了上述的 GPT 架构。
附录
矩阵乘法
在之前的讨论中,我们在嵌入(embedding)的上下文中介绍了向量和矩阵。矩阵有两个维度(即行数和列数)。向量也可以被看作是一个特殊的矩阵,其中一个维度为1。两个矩阵的乘积定义为:
图片来自作者
点表示乘法。现在,让我们再看一看第一张图片中蓝色和有机神经元的计算。如果我们将权重写成一个矩阵,将输入写成向量,那么我们就可以用以下方式表示整个操作:
图片来自作者
如果将权重矩阵称为“W”,输入向量称为“x”,那么 Wx 就是计算结果(在本例中是中间层)。我们也可以将它们转置并写成 xW——这只是个人偏好的问题。
标准差
在“层归一化”部分我们提到了标准差的概念。标准差是一个统计学上的度量,用来表示一组数值的离散程度。例如,如果所有值都相同,那么标准差就是零。如果每个值都远离这些数值的均值,那么标准差就会很高。计算标准差的公式如下:
对于一组数字 a1, a2, a3, ...(假设有 N 个数字),标准差的计算方法如下:
- 计算这些数字的均值。
- 将每个数字与均值的差值平方。
- 将所有差值的平方加起来,并除以 N。
- 对最终结果取平方根。
位置编码
我们在上面提到了位置嵌入(Positional Embedding)。位置编码(Positional Encoding)只是与词嵌入向量(Word Embedding)长度相同的一个向量,但它与词嵌入不同,因为它不是通过训练得到的。我们只是为每个位置分配一个唯一的向量,例如位置 1 的向量与位置 2 的向量不同,以此类推。
一种简单的方法是将该位置的向量填充为该位置的编号。例如,位置 1 的向量可以是 [1, 1, 1,...],位置 2 的向量可以是 [2, 2, 2,...],依此类推(记住,向量的长度必须与词嵌入向量的长度相匹配,才能进行加法操作)。但这种方法有一个问题,那就是我们可能会在训练过程中得到非常大的数值,从而带来挑战。当然,我们可以通过将每个数字除以位置的最大值来归一化这些向量。例如,如果总共有 3 个词,那么位置 1 的向量就是 [.33, .33, ..., .33],位置 2 的向量就是 [.67, .67, ..., .67],以此类推。
但这样做会有一个问题:每个位置的编码会随着输入的变化而变化,这样网络在训练过程中就会面临学习上的困难。因此,我们需要一种方法,分配一个独特的向量给每个位置,并且这些数值不会爆炸。基本的思路是,如果上下文长度为 d(即我们可以输入到网络中的最大令牌/单词数,见“它是如何生成语言的?”部分的讨论),并且嵌入向量的长度为 10(假设),那么我们需要一个 10 行 d 列的矩阵,其中每列都是唯一的,且所有数字都介于 0 和 1 之间。由于在 0 和 1 之间有无限多个数值,而矩阵的尺寸是有限的,因此我们可以通过多种方式来实现这一点。
在《Attention is All You Need》论文中,采用了以下方法来构造位置编码:
为什么选择这种方法?
通过改变 10000 的指数,你实际上是改变了在 p 轴上查看正弦函数的幅度。如果你有 10 条不同幅度的正弦曲线,那么你就需要很长时间才能看到数值的重复(即所有 10 个值完全相同)。这帮助我们为每个位置生成唯一的值。
实际上,这篇论文使用了正弦和余弦函数的组合,编码方式是:
相关书籍推荐:
《深度学习的数学》
作者:[日] 深屿幸一
用浅显易懂的方式剖析深度学习背后的数学原理,与本次文章强调的数学基础学习契合。
《图解机器学习》
作者:[日] 株式会社Cogrowth
本书通过可视化的方式介绍了机器学习的基本概念,帮助读者快速理解复杂算法的逻辑。
《机器学习实战》
作者:[美] Peter Harrington
这是一本经典的入门书籍,内容涵盖了监督学习、无监督学习等核心机器学习概念,帮助读者理解大语言模型的基础算法。
《深度学习入门:基于Python的理论与实现》
作者:[日] 斋藤康毅
本书以浅显易懂的方式介绍了深度学习的基础理论,适合初学者理解深度学习模型的原理和实现。
这些书籍涵盖了从入门到进阶的内容,既适合本文的延伸阅读,也非常契合初学者和有一定基础的读者的学习需求。祝阅读愉快!