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