14个文本转图像AI API
长文:用中学数学知识理解 LLM 大语言模型
一个自包含的、对大型语言模型内部工作原理的完整解释
本文将从零开始讲解大型语言模型 (LLM) 的工作原理——只假设你知道如何对两个数字进行加法和乘法运算。本文旨在完全自包含。我们将首先用笔和纸构建一个简单的生成式人工智能,然后逐步讲解理解现代 LLM 和 Transformer 架构所需的一切知识。本文将去除机器学习中所有花哨的语言和术语,并将所有内容简单地表示为数字。我们仍然会指出事物的名称,以便你在阅读充满术语的内容时能够联系起来。
从加法/乘法到当今最先进的 AI 模型,在不假设其他知识或参考其他资料的情况下,意味着我们将涵盖很多内容。这不是一个玩具 LLM 的解释——一个有决心的人理论上可以根据这里的所有信息重新创建一个现代 LLM。我已经删除了所有不必要的单词/行,因此本文并非真正意义上的浏览读物。
我们将涵盖哪些内容?
- 一个简单的神经网络
- 这些模型是如何训练的?
- 这一切是如何生成语言的?
- 是什么让 LLM 工作得如此出色?
- 嵌入
- 子词分词器
- 自注意力机制
- Softmax
- 残差连接
- 层归一化
- Dropout
- 多头注意力机制
- 位置嵌入
- GPT 架构
- Transformer 架构
让我们开始吧。
首先要注意的是,神经网络只能接受数字作为输入,并且只能输出数字。没有例外。技巧在于弄清楚如何将你的输入作为数字输入,并以一种实现你目标的方式解释输出数字。最后,构建能够接受你提供的输入并给出你想要的输出的神经网络(根据你对这些输出选择的解释)。让我们逐步了解如何从数字的加法和乘法运算到像 Llama 3.1 这样的东西。
一个简单的神经网络:
让我们来看一个可以对物体进行分类的简单神经网络:
- **可用的物体数据:**主色(RGB)和体积(毫升)
- **分类为:**叶子或花朵
以下是叶子和向日葵的数据示例:
现在让我们构建一个进行这种分类的神经网络。我们需要决定输入/输出的解释。我们的输入已经是数字,因此我们可以直接将它们输入到网络中。我们的输出是两个对象,叶子和花朵,神经网络无法输出这些对象。让我们看看这里可以使用的一些方案:
- 我们可以让网络输出一个数字。如果数字是正数,我们就说是叶子;如果数字是负数,我们就说是花朵。
- 或者,我们可以让网络输出两个数字。我们将第一个数字解释为叶子的数字,第二个数字解释为花朵的数字,我们将根据哪个数字更大来进行选择。
这两种方案都允许网络输出可以解释为叶子或花朵的数字。我们在这里选择第二种方案,因为它可以很好地推广到我们稍后将要看到的其他内容。这是一个使用该方案进行分类的神经网络。让我们来看看它的工作原理:
蓝色圆圈的计算方式如下:(32 * 0.10) + (107 * -0.29) + (56 * -0.07) + (11.2 * 0.46) = — 26.6
一些术语:
神经元/节点: 圆圈中的数字
权重: 线上的彩色数字
层: 神经元的集合称为层。你可以认为这个网络有 3 层:具有 4 个神经元的输入层,具有 3 个神经元的中间层和具有 2 个神经元的输出层。
要计算此网络的预测/输出(称为“前向传播”),你需要从左侧开始。我们拥有输入层中神经元的数据。要“向前”移动到下一层,你需要将圆圈中的数字与相应神经元对的权重相乘,然后将它们全部相加。我们在上面演示了蓝色和橙色圆圈的数学运算。运行整个网络,我们看到输出层中的第一个数字更大,因此我们将其解释为“网络将这些 (RGB,Vol) 值分类为叶子”。一个训练有素的网络可以接受各种 (RGB,Vol) 输入并正确地对物体进行分类。
该模型不知道叶子或花朵是什么,也不知道 (RGB,Vol) 是什么。它的工作是只接受 4 个数字并给出 2 个数字。我们将其解释为 4 个输入数字是 (RGB,Vol),并且我们决定查看输出数字并推断如果第一个数字更大,则它是叶子,依此类推。最后,我们还需要选择正确的权重,以便模型能够接受我们的输入数字并给出正确的两个数字,这样当我们解释它们时,就能得到我们想要的解释。
一个有趣的副作用是,你可以使用相同的网络,而不是输入 RGB、Vol,而是输入其他 4 个数字,例如云量、湿度等,并将这两个数字解释为“一小时内晴天”或“一小时内下雨”,然后如果你对权重进行了很好的校准,则可以让完全相同的网络同时完成两件事——对叶子/花朵进行分类并预测一小时内的降雨!网络只给你两个数字,你是否将其解释为分类或预测或其他什么完全取决于你。
为简化而省略的内容(可以忽略而不影响理解):
- 激活层: 这个网络缺少一个关键的东西,那就是“激活层”。这是一个花哨的说法,指的是我们获取每个圆圈中的数字并对其应用一个非线性函数(RELU 是一个常用函数,其中你只需获取数字,如果它是负数,则将其设置为零,如果它是正数,则保持不变)。因此,基本上在我们的例子中,我们将取中间层并将两个数字(-26.6 和 -47.1)替换为零,然后再继续到下一层。当然,我们将不得不重新训练这里的权重以使网络再次有用。如果没有激活层,网络中的所有加法和乘法运算都可以折叠成一个单层。在我们的例子中,你可以将绿色圆圈直接写成 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 …等等。如果我们有一个非线性函数,这通常是不可能的。这有助于网络处理更复杂的情况。
- **偏差:**网络通常还会包含与每个节点关联的另一个数字,这个数字只是添加到乘积中以计算节点的值,这个数字称为“偏差”。因此,如果顶部蓝色节点的偏差为 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(时期)”。然后你可以不断重复这些 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”。我们有了生成式人工智能。此外,我们现在有了一个能够生成语言的网络!_ _现在,没有人真正输入随机分配的数字,我们将在后面看到更合理的方案。如果你等不及了,请随意查看附录中关于独热编码的部分。
敏锐的读者会注意到,我们实际上不能将“Humpty Dumpty”输入到网络中,因为根据图示,它在输入层中只有 12 个神经元,每个神经元对应“humpty dumpt”中的一个字符(包括空格)。那么我们如何在下一次传递中输入“y”呢?在那里放置第 13 个神经元需要我们修改整个网络,这是不可行的。解决方案很简单,让我们去掉“h”,并发送最近的 12 个字符。所以我们将发送“umpty dumpty”,网络将预测一个空格。然后我们将输入“mpty dumpty ”,它将产生一个“s”,以此类推。它看起来像这样:
在最后一行,通过只向模型提供“ sat on the wal”,我们丢弃了很多信息。那么今天最新最好的网络是如何做的呢?或多或少就是这样。我们可以输入到网络中的输入长度是固定的(由输入层的大小决定)。这被称为“上下文长度”——提供给网络进行未来预测的上下文。现代网络可以有非常大的上下文长度(几千个单词),这很有帮助。有一些方法可以输入无限长度的序列,但这些方法的性能虽然令人印象深刻,但已经被其他具有较大(但固定)上下文长度的模型所超越。
细心的读者还会注意到,我们对相同字母的输入和输出有不同的解释!例如,当输入“h”时,我们只是用数字 8 来表示它,但在输出层,我们不是要求模型输出一个数字(“h”为 8,“i”为 9,以此类推……),而是要求模型输出 26 个数字,然后我们看看哪个数字最大,如果第 8 个数字最大,我们就将输出解释为“h”。为什么我们不在两端使用相同的、一致的解释呢?我们可以这样做,只是在语言的情况下,让自己能够在不同的解释之间进行选择,可以让你有更好的机会构建更好的模型。事实证明,目前已知的对输入和输出最有效的解释是不同的。事实上,我们在这个模型中输入数字的方式并不是最好的方法,我们很快就会看到更好的方法。
是什么让大型语言模型如此出色?
逐个字符地生成“Humpty Dumpty sat on a wall”与现代大型语言模型所能做的相去甚远。从我们上面讨论的简单生成式人工智能到像人类一样的机器人,有很多不同之处和创新。让我们来看看它们:
嵌入(Embeddings)
回想一下,我们说过将字符输入模型的方式并非最佳方法。我们只是任意地为每个字符选择了一个数字。如果我们能分配更好的数字,使我们能够训练更好的网络,那会怎么样呢?我们如何找到这些更好的数字呢?这里有一个巧妙的技巧:
当我们训练上面的模型时,我们的做法是四处移动权重,看看最终能否得到更小的损失。然后缓慢地、递归地改变权重。在每次迭代中,我们会:
- 输入数据
- 计算输出层
- 将其与理想的输出进行比较,并计算平均损失
- 调整权重并重新开始
在这个过程中,输入是固定的。当输入是(RGB,Vol)时,这是合理的。但是我们现在为 a、b、c 等输入的数字是我们任意选择的。如果在每次迭代中,除了稍微移动权重之外,我们也移动输入,看看是否可以通过使用不同的数字来表示“a”等来获得更低的损失?我们肯定是在减少损失并使模型变得更好(这是我们移动 ‘a’ 的输入的方向,通过设计)。基本上,不仅对权重应用梯度下降,也对输入的数字表示应用梯度下降,因为它们反正也是任意选择的数字。这被称为“嵌入”。它是输入到数字的映射,正如你刚才看到的,它需要被训练。训练嵌入的过程与训练参数的过程非常相似。然而,这样做的一大优势是,一旦你训练了一个嵌入,你就可以在另一个模型中使用它。请记住,你将始终使用相同的嵌入来表示单个标记/字符/词。
我们讨论了每个字符只有一个数字的嵌入。然而,实际上嵌入有多个数字。这是因为很难用单个数字来捕捉概念的丰富性。如果我们看看我们的叶子和花的例子,每个对象有四个数字(输入层的维度)。这四个数字中的每一个都传达了一个属性,模型能够使用所有这些属性来有效地猜测对象。如果我们只有一个数字,比如颜色的红色通道,那么模型可能会更难。我们在这里试图捕捉人类语言——我们需要不止一个数字。
因此,与其用单个数字表示每个字符,不如用多个数字表示它来捕捉其丰富性?让我们为每个字符分配一组数字。让我们将一个有序的数字集合称为“向量”(有序是指每个数字都有一个位置,如果我们交换两个数字的位置,它会给我们一个不同的向量。我们的叶子/花数据就是这种情况,如果我们交换了叶子的 R 和 G 数字,我们会得到不同的颜色,它将不再是相同的向量)。向量的长度就是它包含的数字个数。我们将为每个字符分配一个向量。出现两个问题:
- 如果我们为每个字符分配了一个向量而不是一个数字,我们现在如何将“humpty dumpt”输入到网络中?答案很简单。假设我们为每个字符分配了一个由 10 个数字组成的向量。“humpty dumpt”中有 12 个字符,每个字符有 10 个数字输入,因此我们将输入层的神经元数量设置为 120 个,而不是 12 个。现在我们只需将神经元彼此相邻放置即可。
- 我们如何找到这些向量?值得庆幸的是,我们刚刚学习了如何训练嵌入数字。训练嵌入向量没有什么不同。你现在有 120 个输入而不是 12 个,但你所做的只是移动它们,看看如何才能最小化损失。然后你取前 10 个,这就是对应于“h”的向量,依此类推。
当然,所有嵌入向量必须具有相同的长度,否则我们将无法将所有字符组合输入到网络中。例如,“humpty dumpt”和下一次迭代“umpty dumpty”——在这两种情况下,我们都在网络中输入 12 个字符,如果 12 个字符中的每一个都不是由长度为 10 的向量表示,我们将无法可靠地将它们全部输入到长度为 120 的输入层中。让我们可视化这些嵌入向量:
让我们将相同大小的向量的有序集合称为矩阵。上面的这个矩阵被称为嵌入矩阵。你告诉它一个与你的字母对应的列号,查看矩阵中的该列将为你提供用于表示该字母的向量。这可以更普遍地应用于嵌入任何任意的事物集合——你只需要在这个矩阵中拥有与你要嵌入的事物一样多的列。
子词分词器(Subword Tokenizers)
到目前为止,我们一直使用字符作为语言的基本构建块。这有其局限性。神经网络权重必须承担大量的繁重工作,它们必须理解某些字符序列(即单词)彼此相邻出现,然后与其他单词相邻出现。如果我们直接将嵌入分配给单词,并让网络预测下一个单词,那会怎么样?网络除了数字之外什么都不懂,所以我们可以为“humpty”、“dumpty”、“sat”、“on”等每个单词分配一个长度为 10 的向量,然后我们只需输入两个单词,它就可以给我们下一个单词。“标记”是指我们嵌入然后输入到模型中的单个单元。到目前为止,我们的模型使用字符作为标记,现在我们建议使用整个单词作为标记(当然,如果你愿意,也可以使用整个句子或短语作为标记)。
使用单词标记化对我们的模型有一个深远的影响。英语中有超过 18 万个单词。使用我们为每个可能的输出设置一个神经元的输出解释方案,我们需要在输出层中有数十万个神经元,而不是 26 个左右。对于现代网络实现有意义结果所需的隐藏层大小,这个问题变得不那么紧迫了。然而,值得注意的是,由于我们分别处理每个单词,并且我们从每个单词的随机数字嵌入开始——非常相似的单词(例如“cat”和“cats”)将以没有关系的状态开始。你会期望这两个单词的嵌入应该彼此接近——这无疑是模型将要学习的。但是,我们能否以某种方式利用这种明显的相似性来抢占先机并简化问题?
是的,我们可以。如今,语言模型中最常见的嵌入方案是将单词分解成子词,然后嵌入它们。在 cat 的例子中,我们将 cats 分解成两个标记“cat”和“s”。现在,模型更容易理解“s”后跟其他熟悉单词的概念,等等。这也减少了我们需要的标记数量(sentencpiece是一种常见的标记器,词汇量选项有数万个,而英语中有数十万个单词)。标记器是一种接收你的输入文本(例如“Humpty Dumpt”),将其拆分为标记,并为你提供需要在嵌入矩阵中查找该标记的嵌入向量的相应数字的东西。例如,在“humpty dumpty”的情况下,如果我们使用字符级标记器,并且我们像上图那样排列嵌入矩阵,那么标记器将首先将 humpty dumpt 拆分为字符 [‘h’,’u’,…’t’],然后返回数字 [8,21,…20],因为你需要查找嵌入矩阵的第 8 列才能获得 ‘h’ 的嵌入向量(嵌入向量是你将输入到模型中的内容,而不是数字 8,不像以前)。矩阵中列的排列完全无关紧要,我们可以将任何列分配给 ‘h’,只要每次输入 ‘h’ 时我们都查找相同的向量,我们就可以了。标记器只是给我们一个任意的(但固定的)数字,以便于查找。我们真正需要它们完成的主要任务是将句子拆分成标记。
使用嵌入和子词标记化,模型可能如下所示:
接下来的几节将讨论语言建模方面的最新进展,以及使大型语言模型像今天这样强大的进展。然而,要理解这些,你需要了解一些基本的数学概念。以下是这些概念:
- 矩阵和矩阵乘法
- 数学中函数的一般概念
- 将数字乘方(例如 a3 = aaa)
- 样本均值、方差和标准差
我在附录中添加了这些概念的摘要。
自注意力机制
到目前为止,我们只见过一种简单的神经网络结构(称为前馈网络),它包含若干层,每一层都与下一层全连接(即,连续层中的任意两个神经元之间都有一条连接线),并且它只连接到下一层(例如,第 1 层和第 3 层之间没有连接线,等等)。然而,可以想象,没有什么能阻止我们移除或建立其他连接,甚至构建更复杂的结构。让我们来探讨一个特别重要的结构:自注意力机制。
如果你观察人类语言的结构,我们想要预测的下一个词将取决于之前的所有词。然而,它们对某些词的依赖程度可能比其他词更大。例如,如果我们试图预测“Damian 有一个秘密的孩子,一个女孩,他在遗嘱中写道,他所有的财产,连同魔法球,都将属于____”这句话中的下一个词。这里的词可能是“她”或“他”,它特别取决于句子中前面出现的词:女孩/男孩。
好消息是,我们简单的前馈模型
连接到上下文中的所有单词,因此它可以学习重要单词的适当权重。但问题是,通过前馈层连接模型中特定位置的权重是固定的(对于每个位置)。如果重要的词总是在同一个位置,它会适当地学习权重,我们就没事了。然而,与下一个预测相关的词可能出现在系统中的任何位置。我们可以改写上面的句子,当猜测“她 vs 他”时,无论“男孩/女孩”出现在句子的哪个位置,它都是一个非常重要的预测词。因此,我们需要权重不仅取决于位置,还取决于该位置的内容。我们如何实现这一点?
自注意力机制的作用类似于将每个单词的嵌入向量相加,但它不是直接相加,而是对每个向量应用一些权重。因此,如果 ‘humpty’、’dumpty’、’sat’ 的嵌入向量分别是 x1、x2、x3,那么它会在将它们相加之前将每个向量乘以一个权重(一个数字)。类似于 output = 0.5 x1 + 0.25 x2 + 0.25 x3,其中 output 是自注意力机制的输出。如果我们将权重写为 u1、u2、u3,使得 output = u1x1+u2x2+u3x3,那么我们如何找到这些权重 u1、u2、u3 呢?
理想情况下,我们希望这些权重取决于我们正在添加的向量——正如我们所看到的,有些向量可能比其他向量更重要。但对谁重要呢?对我们要预测的词来说重要。所以我们也希望权重取决于我们要预测的词。现在有一个问题,我们当然不知道我们要预测的词是什么,在我们预测它之前。因此,自注意力机制使用紧接在我们即将预测的词之前的词,即句子中最后一个可用的词(我真的不知道为什么这样,为什么不是其他东西,但深度学习中的很多东西都是反复试验的,我怀疑这样做效果很好)。
很好,所以我们需要这些向量的权重,并且我们希望每个权重都取决于我们正在聚合的词和紧接在我们即将预测的词之前的词。基本上,我们想要一个函数 u1 = F(x1, x3),其中 x1 是我们将要加权的词,x3 是序列中的最后一个词(假设我们只有 3 个词)。现在,实现这一点的一个直接方法是为 x1 设置一个向量(我们称之为 k1),为 x3 设置一个单独的向量(我们称之为 q3),然后简单地取它们的点积。这将给我们一个数字,它将取决于 x1 和 x3。我们如何得到这些向量 k1 和 q3 呢?我们构建一个微小的单层神经网络,从 x1 到 k1(或 x2 到 k2,x3 到 k3,等等)。我们构建另一个网络,从 x3 到 q3 等等……使用我们的矩阵表示法,我们基本上得到了权重矩阵 Wk 和 Wq,使得 k1 = Wkx1,q1 =Wqx1,等等。现在我们可以取 k1 和 q3 的点积得到一个标量,所以 u1 = F(x1,x3) = Wkx1 · Wqx3。
自注意力机制中发生的另一件事是,我们不直接取嵌入向量本身的加权和。相反,我们取该嵌入向量的某个“值”的加权和,该值是通过另一个小的单层网络获得的。这意味着类似于 k1 和 q1,我们现在也有单词 x1 的 v1,我们通过矩阵 Wv 获得它,使得 v1=Wvx1。然后将这个 v1 聚合。因此,如果我们只有 3 个单词并且我们试图预测第 4 个单词,那么它看起来像这样:
加号表示向量的简单加法,这意味着它们必须具有相同的长度。这里没有显示的最后一个修改是标量 u1、u2、u3 等…不一定加起来等于 1。如果我们需要它们作为权重,我们应该让它们加起来等于 1。所以我们将在这里应用一个熟悉的技巧,并使用 softmax 函数。
这就是自注意力机制。还有交叉注意力机制,其中你可以让 q3 来自最后一个词,但 k 和 v 可以完全来自另一个句子。例如,这在翻译任务中很有价值。现在我们知道了什么是注意力机制。
现在可以把整个东西放在一个盒子里,称之为“自注意力块”。基本上,这个自注意力块接收嵌入向量并输出一个用户选择的任意长度的输出向量。这个块有三个参数,Wk、Wq、Wv——它不需要比这更复杂。机器学习文献中有许多这样的块,它们通常在图中用带有名称的框表示。类似于这样:
你会注意到,到目前为止,自注意力机制中事物的位置似乎并不重要。我们一直在使用相同的 W,因此切换 ‘Humpty’ 和 ‘Dumpty’ 并不会真正改变结果——所有数字最终都会相同。这意味着虽然注意力机制可以找出需要注意什么,但这并不取决于单词的位置。然而,我们确实知道单词位置在英语中很重要,我们可能可以通过让模型感知单词的位置来提高性能。
因此,当使用注意力机制时,我们通常不会将嵌入向量直接馈送到自注意力块。我们稍后将看到如何在将嵌入向量馈送到注意力块之前添加“位置编码”。
给预先了解者的注释: 对于那些不是第一次阅读自注意力机制的人来说,请注意我们没有引用任何 K 和 Q 矩阵,或者应用掩码等等。这是因为这些东西是实现细节,源于这些模型通常是如何训练的。输入一批数据,同时训练模型从 ‘humpty’ 预测 ‘dumpty’,从 ‘humpty dumpty’ 预测 ‘sat’,等等。这是一个提高效率的问题,不会影响解释,甚至不会影响模型输出,我们选择在这里省略训练效率技巧。
Softmax
我们在第一篇笔记中简要地谈到了 softmax。以下是 softmax 试图解决的问题:在我们的输出解释中,我们拥有的神经元数量与我们希望网络从中选择一个的选项数量相同。我们说过,我们将把网络的选择解释为值最高的神经元。然后我们说,我们要计算损失,即网络提供的值与我们想要的理想值之间的差值。但我们想要的理想值是多少?在叶子/花的例子中,我们将其设置为 ‘0.8’。但为什么是 ‘0.8’?为什么不是 ‘5’、’10’ 或 ‘1000 万’?对于该训练示例,越高越好。理想情况下,我们希望它是无穷大!现在,这将使问题难以处理——所有损失都将是无限的,而我们通过调整参数来最小化损失的计划(记住“梯度下降”)将会失败。我们如何处理这个问题?
我们可以做的一件简单的事情是限制我们想要的值。比如说在 ‘0’ 和 ‘1’ 之间?这将使所有损失都是有限的,但现在我们面临的问题是当网络超出范围时会发生什么。假设它在一个案例中为(叶子,花)输出 ‘(5,1)’,在另一个案例中输出 ‘(0,1)’。第一种情况做出了正确的选择,但损失更糟糕!好的,所以现在我们需要一种方法来将最后一层也转换成 ‘(0,1)’ 范围内的输出,以便它保持顺序。我们可以使用任何函数(数学中的“函数”仅仅是将一个数字映射到另一个数字——输入一个数字,输出另一个数字——它根据给定输入的输出进行规则化)来完成这项工作。一种可能的选择是逻辑函数(见下图),它将所有数字映射到 ‘(0,1)’ 之间的数字并保持顺序:
现在,对于最后一层中的每个神经元,我们都有一个 ‘0’ 到 ‘1’ 之间的数字,我们可以通过将正确的神经元设置为 ‘1’,将其他神经元设置为 ‘0’,并取其与网络提供给我们的值的差值来计算损失。这将起作用,但我们能做得更好吗?
回到我们的“矮胖子”示例,假设我们正在尝试逐个字符地生成矮胖子字符,并且我们的模型在预测矮胖子中的“m”时出错。它没有给我们最后一层以“m”作为最高值,而是给了我们“u”作为最高值,但“m”紧随其后。
现在我们可以继续“duu”并尝试预测下一个字符等等,但模型的置信度会很低,因为从“humpty duu..”开始没有那么多好的延续。另一方面,“m”紧随其后,所以我们也可以试一下“m”,预测接下来的几个字符,看看会发生什么?也许它会给我们一个更好的整体单词?
所以我们在这里谈论的不仅仅是盲目地选择最大值,而是尝试几个值。有什么好方法呢?好吧,我们必须为每一个分配一个机会——比如说我们将以 ‘50%’ 的概率选择第一个,以 ‘25%’ 的概率选择第二个,以此类推。这是一个好方法。但也许我们希望这个机会取决于底层的模型预测。如果模型预测这里 m 和 u 的值彼此非常接近(与其他值相比)——那么也许接近 ’50-50′ 的机会来探索这两个值是一个好主意?
所以我们需要一个好的规则来获取所有这些数字并将它们转换为机会。这就是 softmax 的作用。它是上述逻辑函数的推广,但具有附加功能。如果你给它 ’10’ 个任意数字——它会给你 ’10’ 个输出,每个都在 ‘0’ 和 ‘1’ 之间,重要的是,所有 ’10’ 个输出加起来等于 ‘1’,这样我们就可以把它们解释为机会。你几乎会在每个语言模型中发现 softmax 作为最后一层。
残差连接(Residual connections)
随着章节的进展,我们逐渐改变了网络的可视化方式。我们现在使用方框/块来表示某些概念。这种表示法对于表示残差连接这一特别有用的概念非常有用。让我们看一下残差连接与自注意力块的组合:
请注意,我们把“Input”和“Output”放在方框中是为了简化事情,但它们基本上仍然只是一组神经元/数字,与上面显示的相同。
那么这里发生了什么?我们基本上是在获取自注意力块的输出,在将其传递到下一个块之前,我们将原始输入添加到其中。首先要注意的是,这将要求自注意力块输出的维度必须与输入的维度相同。这不是问题,因为正如我们所指出的,自注意力输出是由用户决定的。但为什么要这样做呢?我们在这里不会深入讨论所有细节,但关键是随着网络变得更深(输入和输出之间的层数更多),训练它们变得越来越困难。残差连接已被证明有助于应对这些训练挑战。
层归一化(Layer Normalization)
层归一化是一个相当简单的层,它获取进入该层的数据,并通过减去均值并除以标准差来对其进行归一化(可能还会多一点,如下所示)。例如,如果我们要在输入之后立即应用层归一化,它将获取输入层中的所有神经元,然后计算两个统计数据:它们的均值和标准差。假设均值为 M,标准差为 S,那么层归一化的作用是获取每个神经元,并将其替换为 (x-M)/S,其中 x 表示任何给定神经元的原始值。
那么这有什么帮助呢?它基本上稳定了输入向量,并有助于训练深度网络。一个担忧是,通过归一化输入,我们是否从中删除了一些可能有用的信息,这些信息可能有助于学习有关我们目标的有价值的东西?为了解决这个问题,层归一化层有一个缩放参数和一个偏置参数。基本上,对于每个神经元,你只需将其乘以一个标量,然后加上一个偏置即可。这些标量和偏置值是可以训练的参数。这允许网络学习一些可能对预测有价值的变化。由于这些是唯一的参数,因此 LayerNorm 块没有很多参数需要训练。整个过程如下所示:
Scale 和 Bias 是可训练的参数。你可以看到,层归一化是一个相对简单的块,其中每个数字只进行逐点运算(在初始均值和标准差计算之后)。这让我们想起了激活层(例如 RELU),主要区别在于这里我们有一些可训练的参数(尽管由于简单的逐点运算,参数比其他层少得多)。
标准差是衡量值分散程度的统计量,例如,如果所有值都相同,则标准差为零。一般来说,如果每个值都与其均值相差很远,则标准差会很高。一组数字 a1、a2、a3……(假设有 N 个数字)的标准差计算公式如下:从每个数字中减去均值(这些数字的均值),然后对每个 N 个数字的答案进行平方。将所有这些数字相加,然后除以 N。现在取答案的平方根。
给预先了解情况的人的说明:经验丰富的机器学习专业人员会注意到这里没有讨论批量归一化。事实上,我们在这篇文章中根本没有介绍批量的概念。在大多数情况下,我认为批量是另一种训练加速器,与理解核心概念无关(除了批量归一化,我们这里不需要)。
Dropout
Dropout 是一种简单但有效的避免模型过拟合的方法。过拟合是指你在训练数据上训练模型,它在该数据集上表现良好,但对模型未见过的示例泛化能力较差。帮助我们避免过拟合的技术被称为“正则化技术”,而 Dropout 就是其中之一。
如果你训练一个模型,它可能会在数据上犯错误和/或以特定方式过拟合。如果你训练另一个模型,它可能会做同样的事情,但方式不同。如果你训练多个这样的模型并平均它们的输出会怎样?这些通常被称为“集成模型”,因为它们通过组合来自多个模型的输出来预测输出,并且集成模型的性能通常优于任何单个模型。
在神经网络中,你可以做同样的事情。你可以构建多个(略有不同)的模型,然后组合它们的输出来获得一个更好的模型。然而,这在计算上可能非常昂贵。Dropout 是一种并非完全构建集成模型但确实捕捉了该概念某些精髓的技术。
这个概念很简单,通过在训练期间插入一个 Dropout 层,你所做的就是在插入 Dropout 的层之间随机删除一定百分比的直接神经元连接。考虑到我们的初始网络并在输入层和中间层之间插入一个 Dropout 率为 50% 的 Dropout 层,它看起来像这样:
现在,这迫使网络进行大量的冗余训练。本质上,你是在同时训练多个不同的模型——但它们共享权重。
现在进行推理,我们可以遵循与集成模型相同的方法。我们可以使用 Dropout 进行多次预测,然后将它们组合起来。然而,由于这在计算上非常密集——而且由于我们的模型共享共同的权重——为什么我们不直接使用所有权重进行预测(因此,我们不一次使用 50% 的权重,而是一次使用所有权重)。这应该能让我们大致了解集成模型的效果。
不过有一个问题:使用 50% 权重训练的模型在中间神经元中的数值与使用所有权重训练的模型非常不同。我们想要的是更多集成风格的平均。我们如何做到这一点?好吧,一个简单的方法是简单地将所有权重乘以 0.5,因为我们现在使用的权重是原来的两倍。这就是 Dropout 在推理过程中所做的。它将使用具有所有权重的完整网络,并简单地将权重乘以 (1- p),其中 p 是删除概率。这已被证明作为一种正则化技术效果相当好。
多头注意力
这是 Transformer 架构中的关键模块。我们已经看到了注意力模块是什么。记住,注意力模块的输出是由用户确定的,它是 v 的长度。多头注意力基本上就是并行运行多个注意力头(它们都接受相同的输入)。然后我们获取它们的所有输出并简单地将它们连接起来。它看起来像这样:
请记住,从 v1 -> v1h1 的箭头是线性层——每个箭头上都有一个进行转换的矩阵。我只是为了避免混乱没有显示它们。
这里发生的事情是,我们为每个头生成相同的键、查询和值。但在我们使用这些 k、q、v 值之前,我们基本上在其上应用了线性变换(分别应用于每个 k、q、v,并且分别应用于每个头)。这个额外的层在自注意力中并不存在。
顺便提一句,对我来说,这是一种创建多头注意力的略微令人惊讶的方式。例如,为什么不为每个头创建单独的 Wk、Wq、Wv 矩阵,而不是添加一个新层并共享这些权重。如果你知道,请告诉我——我真的不知道。
位置编码和嵌入
我们在自注意力部分简要讨论了使用位置编码的动机。这些是什么?虽然图片显示的是位置编码,但使用位置嵌入比使用编码更常见。因此,我们在这里讨论一个常见的位置嵌入,但附录也涵盖了原始论文中使用的位置编码。位置嵌入与任何其他嵌入没有什么不同,只是我们嵌入的是数字 1、2、3 等,而不是嵌入单词词汇。因此,这个嵌入是一个与单词嵌入长度相同的矩阵,每一列对应一个数字。就是这样。
GPT 架构
我们来谈谈 GPT 架构。这是大多数 GPT 模型中使用的架构(存在一些差异)。如果你一直关注这篇文章,这应该很容易理解。使用框图表示法,这就是该架构在高级别的外观:
此时,除了“GPT Transformer 模块”之外,所有其他模块都已进行了详细讨论。这里的 + 号只是表示两个向量相加(这意味着两个嵌入必须大小相同)。我们来看看这个 GPT Transformer 模块:
就是这样。它在这里被称为“Transformer”,因为它源自并且是 Transformer 的一种——这是一种我们将在下一节中看到的架构。这并不影响理解,因为我们之前已经涵盖了这里显示的所有构建模块。让我们回顾一下到目前为止我们为构建这个 GPT 架构所涵盖的所有内容:
- 我们看到了神经网络如何接受数字并输出其他数字,并具有可以训练的权重作为参数
- 我们可以将解释附加到这些输入/输出数字上,并赋予神经网络现实世界的意义
- 我们可以将神经网络链接起来以创建更大的网络,我们可以将每个网络称为“模块”,并用一个框表示它,以便使图表更容易理解。每个模块仍然做同样的事情,接受一堆数字并输出另一堆数字
- 我们学习了许多不同类型的模块,它们服务于不同的目的
- GPT 只是这些模块的一种特殊排列,如上图所示,并在第一部分中讨论了它的解释
随着公司构建强大的现代 LLM,随着时间的推移,对此进行了修改,但基本原理保持不变。
现在,这个 GPT Transformer 实际上就是原始 Transformer 论文中引入 Transformer 架构的所谓的“解码器”。让我们来看看它。
Transformer 架构
这是近年来推动语言模型能力快速提升的关键创新之一。Transformer 不仅提高了预测精度,而且比以前的模型更容易/更高效(训练),从而允许更大的模型尺寸。上面提到的 GPT 架构就是基于此。
如果你看一下 GPT 架构,你会发现它非常适合生成序列中的下一个单词。它从根本上遵循我们在第 1 部分中讨论的相同逻辑。从几个单词开始,然后一次继续生成一个。但是,如果你想进行翻译怎么办?如果你有一个德语句子(例如 “Wo wohnst du?” = “Where do you live?”),你想把它翻译成英语,我们该如何训练模型来做这件事?
好吧,我们要做的第一件事就是找到一种输入德语单词的方法。这意味着我们必须扩展我们的嵌入以包含德语和英语。现在,我想这里有一个简单的输入信息的方法。我们为什么不简单地将德语句子连接到目前为止生成的英语的开头,并将其馈送到上下文中呢?为了方便模型处理,我们可以添加一个分隔符。在每一步中,它看起来像这样:
这将起作用,但还有改进的空间:
- 如果上下文长度是固定的,有时原始句子会丢失
- 模型在这里有很多东西要学习。两种语言同时学习,还要知道 是它需要开始翻译的分隔符标记
- 你正在处理整个德语句子,对于每个单词生成都有不同的偏移量。这意味着同一件事会有不同的内部表示,模型应该能够处理所有这些以进行翻译
Transformer 最初就是为此任务而创建的,它由一个“编码器”和一个“解码器”组成——它们基本上是两个独立的块。一个块简单地获取德语句子并给出一个中间表示(同样,基本上是一堆数字)——这被称为编码器。
第二个块生成单词(到目前为止我们已经看到了很多)。唯一的区别是,除了向它提供到目前为止生成的单词之外,我们还向它提供编码的德语(来自编码器块)句子。因此,当它生成语言时,它的上下文基本上是到目前为止生成的所有单词,加上德语。这个块被称为解码器。
这些编码器和解码器中的每一个都由几个块组成,特别是夹在其他层之间的注意力块。让我们看一下论文“Attention is all you need”中的 Transformer 插图,并尝试理解它:
左侧的垂直块组称为“编码器”,右侧的称为“解码器”。让我们回顾并理解我们之前还没有讲过的任何内容:
_回顾如何阅读图表:_这里的每个方框都是一个块,它以神经元的形式接收一些输入,并输出一组神经元作为输出,然后可以由下一个块处理或由我们解释。箭头显示了块的输出流向。如您所见,我们经常将一个块的输出作为输入馈送到多个块中。让我们在这里逐一介绍:
前馈:前馈网络是不包含循环的网络。我们在第 1 节中的原始网络是一个前馈网络。事实上,这个块使用了非常相似的结构。它包含两个线性层,每个线性层后面跟着一个 RELU(参见第一节中关于 RELU 的注释)和一个 dropout 层。请记住,这个前馈网络独立地应用于每个位置。这意味着位置 0 上的信息有一个前馈网络,位置 1 上有一个,依此类推。但是位置 x 的神经元与位置 y 的前馈网络没有链接。这一点很重要,因为如果我们不这样做,它将允许网络在训练期间通过向前看作弊。
_交叉注意力:_你会注意到解码器有一个多头注意力,箭头来自编码器。这里发生了什么?还记得自注意力和多头注意力中的值、键、查询吗?它们都来自相同的序列。事实上,查询只是来自序列的最后一个单词。那么,如果我们保留查询,但从完全不同的序列中获取值和键呢?这就是这里发生的事情。值和键来自编码器的输出。除了键和值的输入来源之外,在数学上没有任何变化。
Nx: 这里的 Nx 只是表示这个块被链式重复了 N 次。所以基本上你是将块一个接一个地堆叠起来,并将前一个块的输入传递给下一个块。这是一种使神经网络更深的方法。现在,查看图表,关于编码器输出如何馈送到解码器,存在一些混淆之处。假设 N=5。我们是否将每个编码器层的输出馈送到相应的解码器层?不。基本上,你只运行一次编码器。然后你只需获取该表示并将相同的内容馈送到 5 个解码器层中的每一个。
Add & Norm 块: 这与下面基本相同(猜想作者只是想节省空间)
其他一切都已讨论过。现在,你已经有了 Transformer 架构的完整解释,它是从简单的加法和乘法运算开始构建的,并且完全自包含!你知道每一行、每一个和、每一个框和每一个词在如何从头构建它们方面的含义。从理论上讲,这些笔记包含了你从头开始编写 Transformer 代码所需的内容。事实上,如果你感兴趣,这个仓库为上面的 GPT 架构做了这件事。
附录
矩阵乘法
我们在上面嵌入的上下文中介绍了向量和矩阵。矩阵有两个维度(行数和列数)。向量也可以被认为是一个维度等于 1 的矩阵。两个矩阵的乘积定义为:
点代表乘法。现在让我们再看一下第一张图中蓝色和有机神经元的计算。如果我们将权重写成矩阵,将输入写成向量,我们可以用以下方式写出整个操作:
如果权重矩阵称为“W”,输入称为“x”,则 Wx 是结果(在本例中为中间层)。我们也可以将两者转置并将其写为 xW——这是一个偏好问题。
标准差
我们在层归一化部分使用了标准差的概念。标准差是衡量数值(在一组数字中)分散程度的统计指标,例如,如果所有值都相同,则标准差为零。一般来说,如果每个值都与这些相同值的平均值相差很远,那么你将拥有一个高标准差。计算一组数字 a1、a2、a3…(假设为 N 个数字)的标准差的公式如下:从每个数字中减去平均值(这些数字的平均值),然后对每个 N 个数字的答案进行平方。将所有这些数字相加,然后除以 N。现在取答案的平方根。
位置编码
上面我们讨论了位置嵌入。位置编码只是一个与词嵌入向量长度相同的向量,但它不是一种嵌入,因为它没有被训练。我们只是简单地给每个位置分配一个唯一的向量,例如,位置 1 一个向量,位置 2 另一个向量,以此类推。一个简单的方法是将该位置的向量填充为位置编号。因此,位置 1 的向量将是 [1,1,1…1]
,位置 2 的向量将是 [2,2,2…2]
,以此类推(记住每个向量的长度必须与嵌入长度匹配才能进行加法运算)。这是有问题的,因为我们最终可能会在向量中得到很大的数字,这会在训练过程中造成挑战。当然,我们可以通过将每个数字除以位置的最大值来归一化这些向量,因此如果总共有 3 个单词,则位置 1 为 [.33,.33,..,.33]
,位置 2 为 [.67, .67, ..,.67]
,以此类推。现在的问题是,我们不断地改变位置 1 的编码(当我们输入 4 个单词的句子时,这些数字会有所不同),这给网络学习带来了挑战。因此,我们想要一种方案,为每个位置分配一个唯一的向量,并且数字不会爆炸。基本上,如果上下文长度为 d(即,我们可以输入到网络中用于预测下一个标记/单词的最大标记/单词数量,请参阅“它是如何生成语言的?”部分中的讨论),并且如果嵌入向量的长度为 10(假设),那么我们需要一个 10 行 d 列的矩阵,其中所有列都是唯一的,并且所有数字都在 0 和 1 之间。鉴于零和一之间有无限多的数字,而矩阵的大小是有限的,这可以通过多种方式实现。
“Attention is all you need”论文中使用的方法如下:
- 绘制 10 条正弦曲线,每条曲线为 si(p) = sin (p/10000^(i/d)) (即 10k 的 i/d 次方)
- 用数字填充编码矩阵,使得 (i,p) 处的数字为 si(p),例如,对于位置 1,编码向量的第 5 个元素为 s5(1)=sin (1/10000^(5/d))
为什么选择这种方法?通过改变 10k 的幂,你就是在改变在 p 轴上观察时正弦函数的振幅。如果你有 10 个不同的正弦函数,具有 10 个不同的振幅,那么对于 p 的变化值,需要很长时间才会出现重复(即所有 10 个值都相同)。这有助于我们获得唯一值。现在,实际的论文同时使用了正弦和余弦函数,编码的形式是:如果 i 为偶数,则 si(p) = sin (p/10000^(i/d));如果 i 为奇数,则 si(p) = cos(p/10000^(i/d))。
文章转自微信公众号@知觉之门