在本文中,我们将从头开始讨论大型语言模型 (LLM) 的工作原理 — 假设您知道如何对两个数字进行加法和乘法。本文旨在完全独立。我们首先用笔和纸构建一个简单的生成式人工智能,然后介绍我们需要的一切,以便对现代 LLM 和 Transformer 架构有扎实的理解。本文将删除 ML 中的所有花哨语言和术语,并将所有内容简单地表示为数字。当您阅读术语内容时,我们仍会指出这些事物的名称,以束缚您的思维。
从加法/乘法到当今最先进的人工智能模型,无需假设其他知识或参考其他来源,这意味着我们涵盖了很多内容。
我们将讨论什么?
- 一个简单的神经网络
- 这些模型是如何训练的?
- 这一切是如何产生语言的?
- 是什么使得大语言模型 (LLM) 如此有效?
- 嵌入
- 子词标记器
- 自注意力
- Softmax
- 剩余连接
- 层规范化
- 辍学
- 多头注意力机制
- 位置嵌入
- GPT 架构
- Transformer 架构
让我们开始吧。
首先要注意的是,神经网络只能将数字作为输入,也只能输出数字。没有例外。技巧在于弄清楚如何将输入作为数字,并以达到目标的方式解释输出数字。最后,构建神经网络,它将接受您提供的输入并为您提供所需的输出(根据您为这些输出选择的解释)。让我们来看看如何从数字的加法和乘法得到像Llama 3.1这样的东西。
一个简单的神经网络:
让我们通过一个可以对物体进行分类的简单神经网络来研究一下:
- **可用对象数据:**主色 (RGB) 和体积(以毫升为单位)
- 分类为:叶或花
一片叶子和一朵向日葵的数据如下:
图片来自作者
现在让我们构建一个进行这种分类的神经网络。我们需要确定输入/输出解释。我们的输入已经是数字,因此我们可以将它们直接输入到网络中。我们的输出是两个对象,即叶子和花朵,神经网络无法输出。让我们看看这里可以使用的几个方案:
- 我们可以让网络输出单个数字。如果数字为正,我们就说它是一片叶子;如果数字为负,我们就说它是一朵花
- 或者,我们可以让网络输出两个数字。我们将第一个数字解释为叶子的数字,将第二个数字解释为花的数字,我们会说选择较大的数字
这两种方案都允许网络输出我们可以解释为叶子或花朵的数字。我们在这里选择第二种方案,因为它可以很好地推广到我们稍后将要讨论的其他事物。这是一个使用此方案进行分类的神经网络。让我们来研究一下:
图片来自作者
一些术语:
神经元/节点:圆圈内的数字
权重:线上的彩色数字
层:神经元的集合称为层。你可以认为这个网络有 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”与现代 LLM 所能做到的相去甚远。有许多差异和创新使我们从上面讨论的简单生成 AI 变成了类似人类的机器人。让我们来看看它们:
嵌入
还记得我们说过,将字符输入模型的方式并不是最好的方式。我们只是随意为每个字符选择一个数字。如果我们可以分配更好的数字,从而使我们能够训练更好的网络,那会怎样?我们如何找到这些更好的数字?这里有一个巧妙的技巧:
当我们训练上述模型时,我们的做法是移动权重,并观察最终导致损失变小的情况。然后缓慢地、递归地改变权重。每次我们都会:
- 输入输入
- 计算输出层
- 将其与我们理想中的输出进行比较,并计算平均损失
- 调整重量并重新开始
在此过程中,输入是固定的。当输入为(RGB,Vol)时,这是有道理的。但是我们现在输入的 a、b、c 等数字是我们任意选择的。如果在每次迭代中,除了稍微移动权重之外,我们还移动输入,看看我们是否可以通过使用不同的数字来表示“a”等来降低损失,会怎么样?我们肯定会减少损失并使模型变得更好(这是我们按照设计移动 a 的输入的方向)。基本上,不仅对权重应用梯度下降,而且对输入的数字表示也应用梯度下降,因为它们无论如何都是任意选择的数字。这称为“嵌入”。它是输入到数字的映射,正如您刚刚看到的,它需要进行训练。训练嵌入的过程与训练参数的过程非常相似。不过,这样做的一个很大的优点是,一旦您训练了嵌入,您就可以在另一个模型中使用它(如果您愿意的话)。请记住,您将始终使用相同的嵌入来表示单个标记/字符/单词。
我们讨论过每个字符只有一个数字的嵌入。然而,实际上嵌入有多个数字。这是因为很难用一个数字来捕捉概念的丰富性。如果我们看看叶子和花朵的例子,每个对象都有四个数字(输入层的大小)。这四个数字中的每一个都传达了一个属性,模型能够使用它们来有效地猜测对象。如果我们只有一个数字,比如颜色的红色通道,那么对模型来说可能会困难得多。我们试图在这里捕捉人类语言——我们需要不止一个数字。
那么,与其用一个数字来表示每个字符,我们也许可以用多个数字来表示它,以捕捉丰富性?让我们为每个字符分配一堆数字。我们将有序的数字集合称为“向量”(有序的意思是每个数字都有一个位置,如果我们交换两个数字的位置,就会得到一个不同的向量。我们的叶子/花朵数据就是这种情况,如果我们交换叶子的 R 和 G 数字,我们会得到不同的颜色,它不再是同一个向量了)。向量的长度就是它包含的数字数量。我们将为每个字符分配一个向量。出现了两个问题:
- 如果我们为每个字符分配一个向量而不是数字,那么我们现在如何将“humpty dumpt”输入到网络?答案很简单。假设我们为每个字符分配一个包含 10 个数字的向量。那么输入层就不会有 12 个神经元,因为“humpty dumpt”中的 12 个字符每个都有 10 个数字要输入,因此我们将在那里放置 120 个神经元。现在我们只需将神经元彼此相邻即可
- 我们如何找到这些向量?幸运的是,我们刚刚学习了如何训练嵌入数字。训练嵌入向量也一样。现在您有 120 个输入,而不是 12 个,但您所做的只是移动它们以查看如何最小化损失。然后您取其中的前 10 个,这就是与“h”对应的向量,依此类推。
当然,所有嵌入向量的长度必须相同,否则我们就无法将所有字符组合输入到网络中。例如“humpty dumpt”,在下一次迭代中为“umpty dumpty”——在这两种情况下,我们都会在网络中输入 12 个字符,如果这 12 个字符中的每一个都没有用长度为 10 的向量表示,我们就无法可靠地将它们全部输入到长度为 120 的输入层中。让我们可视化这些嵌入向量:
图片来自作者
我们将一组有序的相同大小的向量称为一个矩阵。上面的这个矩阵称为嵌入矩阵。你告诉它一个与你的字母相对应的列号,查看矩阵中的该列将为你提供用于表示该字母的向量。这可以更广泛地应用于嵌入任何任意事物的集合——你只需要在这个矩阵中拥有与你拥有的事物一样多的列。
子词标记器
到目前为止,我们一直在使用字符作为语言的基本构成要素。这有其局限性。神经网络权重必须完成大量繁重的工作,它们必须理解某些字符序列(即单词)彼此相邻,然后又与其他单词相邻。如果我们直接将嵌入分配给单词并让网络预测下一个单词,会怎么样?无论如何,网络只理解数字,因此我们可以为每个单词“humpty”、“dumpty”、“sat”、“on”等分配一个长度为 10 的向量。然后我们只需输入两个单词,它就可以给出下一个单词。“标记”是指我们嵌入然后输入模型的单个单元。到目前为止,我们的模型使用字符作为标记,现在我们建议使用整个单词作为标记(当然,如果您愿意,也可以使用整个句子或短语作为标记)。
使用单词标记对我们的模型有深远的影响。英语中有超过 18 万个单词。使用我们的输出解释方案(每个可能的输出都有一个神经元),我们需要在输出层中设置数十万个神经元,而不是 26 个左右。由于现代网络需要隐藏层的大小才能获得有意义的结果,因此这个问题变得不那么紧迫。然而值得注意的是,由于我们分别处理每个单词,并且我们从每个单词的随机数嵌入开始 - 非常相似的单词(例如“cat”和“cats”)将以没有关系开始。你会期望这两个单词的嵌入应该彼此接近 - 毫无疑问模型会学习到这一点。但是,我们能否以某种方式利用这种明显的相似性来快速启动并简化问题?
是的,我们可以。当今语言模型中最常见的嵌入方案是将单词分解为子单词,然后嵌入它们。在猫的例子中,我们将猫分解为两个标记“cat”和“s”。现在,模型更容易理解“s”后面跟着其他熟悉的单词等等的概念。这也减少了我们需要的标记数量(sentencpiece是一种常见的标记器,词汇量选项为几万个,而英语中则是数十万个单词)。标记器会接收您的输入文本(例如“Humpty Dumpt”)并将其拆分为标记,并为您提供相应的数字,您需要在嵌入矩阵中查找该标记的嵌入向量。例如,在“humpty dumpty”的情况下,如果我们使用字符级标记器,并且我们按照上图所示排列嵌入矩阵,那么标记器将首先将 humpty dumpt 拆分为字符 ['h','u',…'t'],然后返回数字 [8,21,…20],因为您需要查找嵌入矩阵的第 8 列以获取“h”的嵌入向量(嵌入向量是您将输入模型的内容,而不是数字 8,与以前不同)。矩阵中列的排列完全无关紧要,我们可以将任何列分配给“h”,只要我们每次输入“h”时查找相同的向量,就没问题。标记器只给我们一个任意(但固定)的数字,以方便查找。我们真正需要它们的主要任务是将句子分成标记。
通过嵌入和子词标记,模型可能看起来像这样:
图片来自作者
接下来的几节将介绍语言建模的最新进展,以及使 LLM 变得如此强大的原因。但是,要理解这些,您需要了解一些基本的数学概念。以下是这些概念:
- 矩阵和矩阵乘法
- 数学中函数的一般概念
- 将数字提升为幂(例如 a3 = a*a*a)
- 样本平均值、方差和标准差
我在附录中添加了这些概念的摘要。
自注意力机制
到目前为止,我们只看到了一种简单的神经网络结构(称为前馈网络),这种网络包含许多层,并且每层都完全连接到下一层(即,连续层中的任意两个神经元之间都有一条线连接),并且它只连接到下一层(例如,第 1 层和第 3 层之间没有线等)。但是,你可以想象,没有什么可以阻止我们移除或建立其他连接。甚至建立更复杂的结构。让我们探索一个特别重要的结构:自注意力。
如果你看一下人类语言的结构,我们想要预测的下一个单词将取决于之前的所有单词。但是,它们可能在更大程度上依赖于它们之前的某些单词。例如,如果我们试图预测“Damian 有一个秘密的孩子,一个女孩,他在遗嘱中写道,他的所有财产,连同魔法球,都将属于 ____”中的下一个单词。这里的这个词可能是“她”或“他”,它具体取决于句子中更早的一个词:女孩/男孩。
好消息是,我们的简单前馈模型连接到上下文中的所有单词,因此它可以学习重要单词的适当权重,但问题是,通过前馈层连接我们模型中特定位置的权重是固定的(对于每个位置)。如果重要单词始终处于同一位置,它将适当地学习权重,一切都会顺利。但是,下一个预测的相关词可能位于系统中的任何位置。我们可以解释上面的句子,当猜测“her vs his”时,对于这个预测非常重要的一个词是 boy/girl,无论它出现在句子的哪个位置。因此,我们需要的权重不仅取决于位置,还需要取决于该位置的内容。我们如何实现这一点?
自注意力机制的作用类似于将每个单词的嵌入向量相加,但不是直接将它们相加,而是对每个向量应用一些权重。因此,如果 humpty、dumpty、sat 的嵌入向量分别为 x1、x2、x3,那么它会将每个向量乘以一个权重(一个数字),然后再将它们相加。例如输出 = 0.5 x1 + 0.25 x2 + 0.25 x3,其中输出是自注意力机制的输出。如果我们将权重写为 u1、u2、u3,使得输出 = 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 个单词并且我们试图预测第四个单词,那么这一切看起来就像这样:
自我关注。图片来自作者
加号表示向量的简单相加,这意味着它们必须具有相同的长度。这里未显示的最后一项修改是标量 u1、u2、u3 等不一定相加为 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 之间?这将使所有损失有限,但现在我们面临的问题是,当网络超调时会发生什么。假设在一种情况下,它对 (leaf,flower) 输出 (5,1),在另一种情况下输出 (0,1)。第一种情况做出了正确的选择,但损失更严重!好的,现在我们需要一种方法来转换 (0,1) 范围内最后一层的输出,以便它保留顺序。我们可以使用任何函数(数学中的“函数”只是一个数字到另一个数字的映射 - 输入一个数字,输出另一个数字 - 它基于给定输入将输出什么的规则)来完成工作。一个可能的选择是逻辑函数(见下图),它将所有数字映射到 (0,1) 之间的数字并保留顺序:
图片来自作者
现在,最后一层的每个神经元都有一个介于 0 和 1 之间的数字,我们可以通过将正确的神经元设置为 1、将其他神经元设置为 0 并从网络提供给我们的值中取差来计算损失。这可行,但我们能做得更好吗?
回到我们的“Humpty dumpty”示例,假设我们尝试逐个字符生成 dumpty,而我们的模型在预测 dumpty 中的“m”时犯了一个错误。它没有给出最后一层以“m”为最高值,而是给出了“u”作为最高值,但“m”紧随其后。
现在我们可以继续使用“duu”并尝试预测下一个字符等等,但模型置信度会很低,因为“humpty duu..”没有那么多好的延续。另一方面,“m”紧随其后,所以我们也可以试试“m”,预测接下来的几个字符,看看会发生什么?也许它会给我们一个更好的整体词?
因此,我们在这里讨论的不是盲目地选择最大值,而是尝试一些。有什么好办法吗?好吧,我们必须为每个值分配一个机会——比如说,我们将以 50% 的概率选择第一个,以 25% 的概率选择第二个,依此类推。这是一个很好的方法。但也许我们希望机会取决于底层模型的预测。如果模型预测 m 和 u 的值在这里非常接近(与其他值相比)——那么探索这两个值的 50% 的概率也许是个好主意?
因此,我们需要一个好的规则,将所有这些数字转换为机会。这就是 softmax 的作用。它是上述逻辑函数的泛化,但具有附加功能。如果你给它 10 个任意数字 — — 它会给你 10 个输出,每个输出介于 0 和 1 之间,重要的是,所有 10 个输出加起来都为 1,这样我们就可以将它们解释为机会。你会发现 softmax 几乎是每个语言模型的最后一层。
剩余连接
随着章节的进展,我们慢慢改变了网络的可视化。我们现在使用框/块来表示某些概念。这种符号在表示残差连接这一特别有用的概念时很有用。让我们看看残差连接与自注意力块的结合:
残留连接。图片由作者提供
请注意,我们将“输入”和“输出”作为框以使事情更简单,但它们基本上仍然只是神经元/数字的集合,如上所示。
那么这里发生了什么?我们基本上是获取自注意力块的输出,在将其传递到下一个块之前,我们将原始输入添加到其中。首先要注意的是,这要求自注意力块输出的维度现在必须与输入的维度相同。这不是问题,因为正如我们注意到的,自注意力输出是由用户决定的。但为什么要这样做呢?我们不会在这里讨论所有细节,但关键是随着网络变得越来越深(输入和输出之间的层数越多),训练它们变得越来越困难。残差连接已被证明有助于应对这些训练挑战。
层规范化
层规范化是一个相当简单的层,它接收进入层的数据并通过减去平均值并除以标准差(可能更多,如下所示)对其进行规范化。例如,如果我们在输入后立即应用层规范化,它将获取输入层中的所有神经元,然后计算两个统计数据:它们的平均值和标准差。假设平均值为 M,标准差为 D,那么层规范所做的就是获取每个神经元并将其替换为 (xM)/D,其中 x 表示任何给定神经元的原始值。
那么这有什么帮助呢?它基本上可以稳定输入向量并有助于训练深度网络。一个问题是,通过规范化输入,我们是否会从中删除一些有用的信息,而这些信息可能有助于我们了解一些关于我们目标的有价值的东西?为了解决这个问题,层规范层有一个尺度和一个偏差参数。基本上,对于每个神经元,你只需将其与一个标量相乘,然后向其添加一个偏差。这些标量和偏差值是可以训练的参数。这允许网络学习一些可能对预测有价值的变化。而且由于这些是唯一的参数,LayerNorm 块没有很多参数需要训练。整个事情看起来像这样:
层规范化。图片来自作者
Scale 和 Bias 是可训练的参数。您可以看到层规范是一个相对简单的块,其中每个数字仅按点进行操作(在初始平均值和标准差计算之后)。这让我们想起了激活层(例如 RELU),主要区别在于这里我们有一些可训练的参数(尽管由于简单的按点操作,这些参数比其他层少很多)。
标准差是数值分散程度的统计度量,例如,如果数值全部相同,则标准差为零。一般来说,如果每个数值与这些相同数值的平均值相差甚远,则标准差会很高。计算一组数字 a1、a2、a3……(假设有 N 个数字)的标准差的公式大致如下:从每个数字中减去(这些数字的)平均值,然后对 N 个数字中的每个数字求平方。将所有这些数字相加,然后除以 N。现在对答案求平方根。
初学者请注意:经验丰富的 ML 专业人士会注意到,这里没有讨论批量规范。事实上,我们甚至没有在本文中介绍批量的概念。在大多数情况下,我相信批量是另一种与理解核心概念无关的训练加速器(也许除了批量规范,我们在这里不需要它)。
辍学
Dropout 是一种简单但有效的避免模型过拟合的方法。过拟合是指当您使用训练数据训练模型时,模型在该数据集上表现良好,但无法很好地推广到模型未见过的示例。帮助我们避免过拟合的技术称为“正则化技术”,Dropout 就是其中之一。
如果您训练一个模型,它可能会以某种方式对数据产生错误和/或过度拟合。如果您训练另一个模型,它可能会以不同的方式产生相同的结果。如果您训练了多个这样的模型并计算了输出的平均值,结果会怎样?这些模型通常被称为“集成 模型”,因为它们通过组合来自集成模型的输出来预测输出,并且集成模型通常比任何单个模型都表现更好。
在神经网络中,你可以做同样的事情。你可以构建多个(略有不同的)模型,然后组合它们的输出以获得更好的模型。但是,这可能在计算上很昂贵。Dropout 是一种不能完全构建集成模型但确实抓住了该概念的一些精髓的技术。
这个概念很简单,通过在训练期间插入一个 dropout 层,你所做的就是随机删除插入 dropout 的层之间一定比例的直接神经元连接。考虑我们的初始网络,在输入和中间层之间插入一个 Dropout 层,dropout 率为 50%,看起来就像这样:
图片来自作者
现在,这迫使网络在训练时产生大量冗余。本质上,你同时训练了许多不同的模型 — — 但它们共享权重。
现在,为了进行推理,我们可以采用与集成模型相同的方法。我们可以使用 dropouts 进行多次预测,然后将它们组合起来。但是,由于这需要大量计算 - 而且我们的模型共享共同的权重 - 我们为什么不使用所有权重进行预测(因此,我们不是一次使用 50% 的权重,而是同时使用所有权重)。这应该可以让我们大致了解集成将提供什么。
但有一个问题:使用 50% 权重训练的模型在中间神经元中的数字与使用所有权重的模型在中间神经元中的数字会有很大不同。我们想要的是更多的集成式平均。我们怎么做呢?嗯,一个简单的方法是简单地取所有权重并将它们乘以 0.5,因为我们现在使用的权重是原来的两倍。这就是 Droput 在推理过程中所做的。它将使用具有所有权重的完整网络,并将权重乘以 (1- p),其中 p 是删除概率。这已被证明是一种非常有效的正则化技术。
多头注意力机制
这是 Transformer 架构中的关键模块。我们已经了解了注意力模块是什么。请记住,注意力模块的输出由用户决定,即 v 的长度。多注意力头基本上就是并行运行多个注意力头(它们都采用相同的输入)。然后我们获取所有输出并简单地将它们连接起来。它看起来像这样:
多头注意力机制。图片来自作者
请记住,从 v1 -> v1h1 的箭头是线性层 — 每个箭头上都有一个变换矩阵。我只是为了避免混乱才没有展示它们。
这里发生的事情是,我们为每个 head 生成相同的键、查询和值。但在使用这些 k、q、v 值之前,我们基本上是在此基础上应用线性变换(分别对每个 k、q、v 和每个 head 分别应用)。自注意力机制中不存在这个额外的层。
附带说明一下,对我来说,这是一种创建多头注意力的略显令人惊讶的方式。例如,为什么不为每个头创建单独的 Wk、Wq、Wv 矩阵,而不是添加新层并共享这些权重。如果你知道的话请告诉我——我真的不知道。
位置编码和嵌入
我们在自注意力部分简要讨论了使用位置编码的动机。这些是什么?虽然图片显示了位置编码,但使用位置嵌入比使用编码更常见。因此,我们在这里讨论一种常见的位置嵌入,但附录还介绍了原始论文中使用的位置编码。位置嵌入与任何其他嵌入没有什么不同,只是我们将嵌入数字 1、2、3 等而不是嵌入词汇表。因此,这个嵌入是一个与词嵌入长度相同的矩阵,每列对应一个数字。这就是它的全部内容。
GPT 架构
让我们来谈谈 GPT 架构。这是大多数 GPT 模型中使用的架构(存在差异)。如果您到目前为止一直在关注本文,那么理解这一点应该相当容易。使用方框符号,架构在高层次上看起来如下:
GPT 架构。图片由作者提供
至此,除了“GPT Transformer Block”之外,所有其他块都已详细讨论过。这里的 + 号只是表示两个向量相加(这意味着两个嵌入必须具有相同的大小)。让我们看看这个 GPT Transformer Block:
这就是全部了。这里之所以称之为“transformer”,是因为它衍生自 transformer,并且是一种 transformer 类型——我们将在下一节中讨论这种架构。这并不影响理解,因为我们之前已经介绍了这里显示的所有构建块。让我们回顾一下到目前为止我们在构建这个 GPT 架构时所介绍的所有内容:
- 我们了解了神经网络如何接收数字并输出其他数字,以及如何将权重作为可训练的参数
- 我们可以对这些输入/输出数字进行解释,并赋予神经网络现实世界的意义
- 我们可以将神经网络串联起来,形成更大的网络,我们可以将每个网络称为一个“块”,并用一个框来表示,以便于图表的绘制。每个块仍然做同样的事情,接收一堆数字并输出另一堆数字
- 我们学习了很多不同类型的积木,它们有不同的用途
- GPT 只是这些块的一种特殊排列,如上所示,其解释我们在第 1 部分中讨论过
随着公司逐渐建立起强大的LLM,对此进行了修改,但基本内容保持不变。
现在,这个 GPT Transformer 实际上就是在介绍 Transformer 架构的原始 Transformer 论文中所谓的“解码器”。让我们来看看。
Transformer 架构
这是最近推动语言模型功能快速发展的关键创新之一。Transformer 不仅提高了预测准确性,而且比以前的模型(训练)更容易/更高效,允许更大的模型尺寸。这就是上述 GPT 架构所基于的。
如果你看一下 GPT 架构,你会发现它非常适合生成序列中的下一个单词。它基本上遵循我们在第 1 部分中讨论的相同逻辑。从几个单词开始,然后继续一次生成一个。但是,如果你想做翻译怎么办。如果你有一句德语句子(例如“Wo wohnst du?”=“你住在哪里?”)并且你想把它翻译成英语怎么办。我们如何训练模型来做到这一点?
好吧,我们需要做的第一件事就是想办法输入德语单词。这意味着我们必须扩展我们的嵌入以包含德语和英语。现在,我想这是一种输入信息的简单方法。我们为什么不直接将德语句子连接到迄今为止生成的英语的开头并将其提供给上下文呢?为了让模型更容易,我们可以添加一个分隔符。这在每个步骤中看起来都像这样:
图片来自作者
这会起作用,但还有改进的空间:
- 如果上下文长度固定,有时原句会丢失
- 模型在这里有很多东西需要学习。同时翻译两种语言,还要知道 是分隔符,它需要从这里开始翻译
- 您正在处理整个德语句子,每次生成单词时偏移量都不同。这意味着同一事物会有不同的内部表示,模型应该能够处理所有这些内容以进行翻译
Transformer 最初就是为这项任务而设计的,它由“编码器”和“解码器”组成,这两个模块基本上是两个独立的块。其中一个模块只是接收德语句子并给出一个中间表示(基本上也是一堆数字),这被称为编码器。
第二个块生成单词(到目前为止我们已经见过很多了)。唯一的区别是,除了输入到目前为止生成的单词外,我们还输入编码的德语句子(来自编码器块)。因此,当它生成语言时,它的上下文基本上是到目前为止生成的所有单词加上德语。这个块称为解码器。
这些编码器和解码器均由几个块组成,特别是夹在其他层之间的注意力块。让我们看一下论文“Attention is all you need”中变压器的图示,并尝试理解它:
图片来自 Vaswani 等人(2017 年)
左侧的垂直块组称为“编码器”,右侧的垂直块组称为“解码器”。让我们回顾一下并了解之前尚未涉及的内容:
_回顾一下如何阅读该图:_这里的每个框都是一个块,它以神经元的形式接收一些输入,并输出一组神经元作为输出,然后可以由下一个块处理或由我们解释。箭头显示块的输出去向。如您所见,我们通常会获取一个块的输出并将其作为输入输入到多个块中。让我们逐一介绍一下:
前馈:前馈网络是不包含循环的网络。第 1 节中的原始网络就是前馈网络。事实上,此块使用的结构非常相似。它包含两个线性层,每个层后跟一个 RELU(请参阅第一节中关于 RELU 的注释)和一个 dropout 层。请记住,此前馈网络独立应用于每个位置。这意味着位置 0 上的信息具有前馈网络,位置 1 上的信息具有前馈网络,依此类推。但是来自位置 x 的神经元与位置 y 的前馈网络没有联系。这很重要,因为如果我们不这样做,它将允许网络在训练时通过向前看来作弊。
_交叉注意力:_你会注意到解码器具有多头注意力,箭头来自编码器。这是怎么回事?还记得自注意力和多头注意力中的值、键、查询吗?它们都来自同一个序列。事实上,查询只是来自序列的最后一个字。那么,如果我们保留查询,但从完全不同的序列中获取值和键,会怎么样?这就是这里发生的事情。值和键来自编码器的输出。除了键和值的输入来自哪里之外,数学上没有任何变化。
Nx:这里的 Nx 只是表示这个块被链式重复了 N 次。所以基本上,你是在背靠背堆叠块,并将前一个块的输入传递到下一个块。这是一种使神经网络更深的方法。现在,看这个图,可能会对编码器输出如何馈送到解码器产生混淆。假设 N=5。我们是否将每个编码器层的输出馈送到相应的解码器层?不。基本上,你只运行一次编码器。然后,你只需采用该表示并将相同的东西馈送到 5 个解码器层中的每一个。
添加和规范块:这基本上与下面的相同(猜测作者只是想节省空间)
图片来自作者
其他一切都已经讨论过了。现在,您已经对从简单的求和和乘法运算构建的变压器架构有了完整的解释,并且完全独立!您知道每行、每个总和、每个框和单词在如何从头开始构建它们方面意味着什么。从理论上讲,这些注释包含了您从头开始编写变压器所需的内容。事实上,如果您感兴趣,这个 repo可以针对上面的 GPT 架构做到这一点。
附录
矩阵乘法
我们在上文的嵌入上下文中介绍了向量和矩阵。矩阵有两个维度(数字或行和列)。向量也可以被认为是其中一个维度等于一的矩阵。两个矩阵的乘积定义为:
图片来自作者
点代表乘法。现在让我们再看一下第一张图片中蓝色和有机神经元的计算。如果我们将权重写成矩阵,将输入写成向量,我们可以按以下方式写出整个操作:
图片来自作者
如果权重矩阵称为“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 之间。鉴于 0 和 1 之间有无数个数字,并且矩阵的大小有限,因此可以通过多种方式来实现。
《注意力就是你所需要的》这篇论文中使用的方法如下:
- 绘制 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 个不同的幅度,那么要花很长时间才能获得重复(即所有 10 个值都相同)来改变 p 的值。这有助于为我们提供唯一的值。现在,实际的论文同时使用正弦和余弦函数,编码形式为:如果 i 为偶数,则 si(p) = sin (p/10000(i/d));如果 i 为奇数,则 si(p) = cos(p/10000(i/d))。