Lazy loaded image
技术
✔️从零开始理解LLM
Words 17232Read Time 44 min
2025-3-12
2025-3-12
type
status
date
slug
summary
tags
category
icon
password
comment
Status
 
首先要明确一点神经网络只能接受数字作为输入,并且只能输出数字。没有例外。重点在于弄清楚如何喂入数字输入,并按照一定方式解释输出的数字来实现目标。最后,构建神经网络,使其能够接受提供的输入并给出想要的输出(并对这些输出提供合理的解释)。让我们来看看如何利用加法和乘法运算构建出像 Llama 3.1 这样的大模型。
 

A simple neural network: 一个简单的神经网络

让我们先实现一个简单分类的神经网络
输入:待分类的对象数据,颜色(RGB)和体积(以毫升为单位) 输出:分类叶子和花
notion image
 
现在让我们构建一个区分叶子和花的神经网络。我们需要决定输入/输出的解释。我们的输入已经是数字,因此我们可以直接将它们输入到网络中。我们的输出是两个对象,叶子和花,神经网络无法直接输出。我们可以让网络输出两个数字。我们将第一个数字解释为叶子的数量,第二个数字解释为花的数量,数字越大越接近叶子。
notion image
 
 
Neurons/nodes 神经元/节点: 圆圈中的数字 Weights 权重: 线上的彩色数字 Layers 层: 一组神经元称为一层。可以把这个网络看作有 3 层:输入层有 4 个神经元,中间层有 3 个神经元,输出层有 2 个神经元。 从左侧开始计算这个网络的预测/输出(称为“前向传播”)。我们有输入层中神经元的数据,要“向前”移动到下一层,就是将圆圈中的数字与相应神经元配对的权重相乘,然后将它们全部相加。我们在上面演示了蓝色和橙色圆圈的数学运算。运行整个网络,我们看到输出层中的第一个数字更高,因此我们将其解释为“网络将这些(RGB,Vol)值分类为叶子”。一个训练良好的网络可以接受各种(RGB,Vol)输入并正确分类对象。 模型并不知道什么是叶子或花,或者什么是(RGB,Vol)。它的任务是接收 4 个数字并输出正好 2 个数字。我们解释这 4 个输入数字是(RGB,Vol),同时我们决定查看输出数字并推断如果第一个数字更大,它就是叶子,依此类推。最后,选择合适的权重也是我们的责任,以便模型能够接收我们的输入数字并给出正确的两个数字,以便在我们解释时得到我们想要的结果。
一个有趣的副作用是,你可以使用相同的网络,而不是输入 RGB,改为输入其他四个数字,比如云量、湿度等,并将这两个数字解释为“一个小时内晴天”或“一个小时内下雨”。如果你有良好校准的权重,你可以让同一个网络同时做两件事——分类叶子/花朵和预测一个小时内的降雨!网络只给你两个数字,无论你将其解释为分类、预测还是其他什么,完全取决于你。
notion image
激活层(Activation layer):这个网络缺少的一个关键部分是“激活层”。这是一个口语化的说法,意思是我们对每个圆圈中的数字应用一个非线性函数(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”。然后,您可以不断重复这些周期,从而找到减少平均损失的权重。
  • 我们实际上不需要“移动权重”来计算每个权重的梯度——我们可以直接从公式中推断出来(例如,如果在最后一步权重是 0.17,神经元的值是正的,并且我们希望输出的数字更大,我们可以看到将这个数字增加到 0.18 会有所帮助)。
在实践中,训练深度网络是一个困难而复杂的过程,因为梯度在训练过程中很容易失控,可能会趋近于零或无穷大(称为“消失梯度”和“爆炸梯度”问题)。我们在这里讨论的损失的简单定义是完全有效的,但很少使用,因为有更好的功能形式适用于特定目的。随着现代模型包含数十亿个参数,训练一个模型需要大量的计算资源,这也带来了自身的问题(内存限制、并行化等)。
 

这些如何帮助生成语言?

 
记住,神经网络输入一些数字,根据训练的参数进行一些数学运算,然后输出其他数字。一切都与解释和训练参数(即将它们设置为某些数字)有关。如果我们可以将这两个数字解释为“叶子/花”或“一小时内的雨或阳光”,我们也可以将它们解释为“句子中的下一个字符”。
 
但是英语中有超过两个字母,因此我们必须将输出层中的神经元数量扩展到,例如,英语中的 26 个字母(我们还可以加入一些符号,如空格、句号等)。每个神经元可以对应一个字符,我们查看输出层中的(大约 26 个)神经元,并说输出层中编号最高的神经元对应的字符就是输出字符。现在我们有了一个可以接受一些输入并输出一个字符的网络。
 
如果我们用这些字符“Humpty Dumpt”替换我们网络中的输入,并要求它输出一个字符,并将其解释为“网络对我们刚刚输入的序列中下一个字符的建议”。我们可能可以将权重设置得足够好,以便它输出“y”——从而完成“Humpty Dumpty”。除了一个问题,我们如何将这些字符列表输入到网络中?我们的网络只接受数字!!
 
一个简单的解决方案是给每个字符分配一个数字。假设 a=1,b=2,以此类推。现在我们可以输入“humpty dumpt”,并训练它给我们“y”。我们的网络看起来像这样:
notion image
 
好的,现在我们可以通过提供一个字符列表来预测下一个字符。我们可以利用这一点来构建整个句子。例如,一旦我们预测出“y”,我们就可以将这个“y”附加到我们拥有的字符列表中,并将其输入到网络中,要求它预测下一个字符。如果训练得当,它应该给我们一个空格,依此类推。最终,我们应该能够递归生成“Humpty Dumpty sat on a wall”。我们有生成性人工智能。此外,我们现在有一个能够生成语言的网络!现在,没有人会实际输入随机分配的数字,我们将在后面看到更合理的方案。如果你等不及,可以随时查看附录中的独热编码部分。
精明的读者会注意到,我们实际上无法将“humpty dumpty”输入到网络中,因为图表的方式是,它在输入层只有 12 个神经元,每个神经元对应“humpty dumpt”中的一个字符(包括空格)。那么我们如何在下一次传递中输入“y”呢?在这里放置第 13 个神经元将要求我们修改整个网络,这并不可行。解决方案很简单,让我们把“h”去掉,发送 12 个最近的字符。因此,我们将发送“umpty dumpty”,网络将预测一个空格。然后我们将输入“mpty dumpty”,它将产生一个“s”,依此类推。它看起来像这样:
notion image
 
我们在最后一行中丢弃了很多信息,因为只给模型输入“ sat on the wal”。那么今天最新最好的网络做了什么呢?或多或少正是这样。我们可以输入到网络中的输入长度是固定的(由输入层的大小决定)。这被称为“上下文长度”——提供给网络以进行未来预测的上下文。现代网络可以具有非常大的上下文长度(几千个单词),这有助于。有一些输入无限长度序列的方法,但这些方法的性能虽然令人印象深刻,但已经被其他具有大(但固定)上下文长度的模型所超越。
 
另一个细心的读者会注意到,我们对同一字母的输入和输出有不同的解释!例如,当输入“h”时,我们只是用数字 8 来表示它,但在输出层,我们并不是要求模型输出一个单一的数字(“h”对应 8,“i”对应 9,依此类推……),而是要求模型输出 26 个数字,然后我们查看哪个数字最高,如果第 8 个数字最高,我们就将输出解释为“h”。为什么我们不在两端使用相同、一致的解释呢?我们可以这样做,只是就语言而言,允许自己在不同的解释之间选择,可以更好地构建更好的模型。而且恰好目前已知的输入和输出的最有效解释是不同的。实际上,我们在这个模型中输入数字的方式并不是最好的,我们将很快看看更好的方法。

大型语言模型为何如此有效?

逐字生成 “Humpty Dumpty sat on a wall” 与现代 LLMs 的能力相去甚远。从我们上面讨论的简单生成式人工智能到类人机器人之间有许多差异和创新。让我们逐一了解这些内容:

Embeddings 嵌入

我们输入字符到模型中的方式并不是最好的方法。我们只是随意为每个字符选择了一个数字。如果我们可以分配更好的数字,使我们能够训练更好的网络,那该怎么办?我们如何找到这些更好的数字?这里有一个聪明的技巧:
当我们训练上述模型时,我们的做法是调整权重,观察这是否能最终带来更小的损失。然后逐渐并递归地改变权重。步骤:
  • Feed in the inputs 输入数据
  • Calculate the output layer 计算输出层
  • Compare it to the output we ideally want and calculate the average loss
    • 将其与我们理想中想要的输出进行比较,并计算平均损失
  • Adjust the weights and start again 调整权重并重新开始
在这个过程中,输入是固定的。当输入是(RGB,Vol)时,这是有道理的。但我们现在输入的数字 a、b、c 等是我们任意选择的。如果在每次迭代中,除了稍微调整权重外,我们还可以移动输入,看看是否可以通过使用不同的数字来表示“a”等,而获得更低的损失呢?我们通过移动 a 输入的方向以减少损失并使模型有更好的表现。所以我们不仅对权重应用梯度下降,还对输入的数字表示 应用梯度下降,这被称为“嵌入”。它是输入到数字的映射,正如你刚才看到的,它需要被训练。训练嵌入的过程与训练参数的过程非常相似。不过,这样的一个大优势是,一旦你训练了一个嵌入,如果你愿意,可以在另一个模型中使用它。请记住,你将始终使用相同的嵌入来表示单个标记/字符/单词。 我们谈到了每个字符只有一个数字的嵌入。然而,实际上嵌入有多个数字。这是因为用一个数字很难捕捉概念的丰富性。如果我们看一下叶子和花的例子,每个对象都有四个数字(输入层的大小)。这四个数字传达了一个属性,模型能够利用它们有效地猜测对象。如果我们只有一个数字,比如颜色的红色通道,模型可能会更难。我们试图捕捉人类语言——我们需要的不止一个数字。
 
所以与其用一个数字来表示每个字符,不如用多个数字来捕捉其丰富性。我们给每个字符分配一组数字。我们将有序的数字集合称为“向量”(有序是指每个数字都有一个位置,如果我们交换两个数字的位置,就会得到一个不同的向量。我们的叶子/花朵数据就是这种情况,如果我们交换叶子的 R 和 G 数字,我们会得到不同的颜色,它就不再是同一个向量了)。向量的长度就是它包含的数字数量。我们将为每个字符分配一个向量。由此产生两个问题:
  • 如果我们为每个字符分配一个向量而不是一个数字,那么我们现在如何将“humpty dumpt”输入到网络中呢?答案很简单。假设我们为每个字符分配了一个包含 10 个数字的向量。那么输入层就不是有 12 个神经元,而是有 120 个神经元,因为“humpty dumpt”中的 12 个字符每个都有 10 个数字要输入。现在我们只需将神经元并排放置,就可以开始了。
  • 我们如何找到这些向量?我们刚刚学会了如何训练嵌入数字。训练嵌入向量没有什么不同。你现在有 120 个输入,而不是 12 个,但你所做的只是移动它们,以查看如何最小化损失。然后确定效果最好的 10 个,这就是对应于“h”的向量,依此类推。
所有的嵌入向量当然必须具有相同的长度,否则我们将无法将所有字符组合输入到网络中。例如,“humpty dumpt”和下一次迭代中的“umpty dumpty”——在这两种情况下,我们都在网络中输入 12 个字符,如果这 12 个字符中的每一个没有由长度为 10 的向量表示,我们将无法可靠地将它们全部输入到一个 120 长的输入层中。让我们来可视化这些嵌入向量:
notion image
我们称一组相同大小的有序向量为矩阵。上面的这个矩阵称为嵌入矩阵。你告诉它一个与字母对应的列号,查看矩阵中的该列将给你提供用于表示该字母的向量。这可以更一般地应用于嵌入任何任意集合的事物——只需要在这个矩阵中嵌入与事物数量相同的列。

Subword Tokenizers 子词分词器

 
到目前为止,我们一直在将字符作为语言的基本构建块。这有其局限性。神经网络的权重需要承担大量的任务,它们必须理解某些字符(即单词)相互出现的特定序列,然后再与其他单词相邻。如果我们直接将嵌入分配给单词,并让网络预测下一个单词呢?反正网络只理解数字,因此我们可以为每个单词“humpty”、“dumpty”、“sat”、“on”等分配一个长度为 10 的向量,然后我们只需输入两个单词,它就可以给我们下一个单词。“Token”是我们嵌入并输入到模型中的单个单位的术语。到目前为止,我们的模型使用字符作为标记,现在我们提议使用整个单词作为标记(当然,如果你愿意,也可以使用整个句子或短语作为标记)。 英语中有超过 18 万个单词。使用我们输出解释方案的每个可能输出一个神经元,我们需要数十万个神经元在输出层,而不是大约 26 个。但是考虑到实现现代网络有意义的结果所需的隐藏层大小,这个问题变得不那么紧迫。然而,值得注意的是,由于我们将每个单词单独处理,并且我们为每个单词开始时使用随机的数字嵌入——非常相似的单词(例如“cat”和“cats”)将没有任何关系。你会期望这两个单词的嵌入应该彼此接近——毫无疑问,模型会学习到这一点。但是,我们能否以某种方式利用这种明显的相似性来获得一个起步并简化问题?
 
是的,当然可以。如今语言模型中最常见的嵌入方案是将单词分解为子词,然后进行嵌入。在猫的例子中,我们将“cats”分解为两个标记“cat”和“s”。现在模型更容易理解“s”后面跟着其他熟悉单词的概念。这也减少了我们需要的标记数量(sentencpiece 是一个常见的分词器,其词汇量选项在数万到数十万的英语单词之间)。分词器是将输入文本(例如“Humpty Dumpt”)拆分为标记(tokens ),并给出相应的数字,以便在嵌入矩阵中查找该标记的嵌入向量。
例如,在“humpty dumpty”的情况下,如果我们使用字符级分词器(tokenizer )并且我们按照上面的图片排列我们的嵌入矩阵,那么标记器将首先将 humpty dumpt 拆分为字符[‘h’,’u’,…’t’],然后返回数字[8,21,…20],因为你需要查找嵌入矩阵的第 8 列以获取‘h’的嵌入向量(嵌入向量是你将输入到模型中的内容,而不是数字 8)。矩阵中列的排列完全无关紧要,我们可以将任何列分配给‘h’,只要每次输入‘h’时查找相同的向量。分词器只是给我们一个任意(但固定)的数字以便于查找。我们真正需要它们的主要任务是将句子拆分为标记。
 
通过嵌入和子词标记化,模型可能看起来像这样:
notion image
接下来的几个部分将讨论现在大语言模型的进展,以及为何 LLMs 如此强大。然而,要理解这些,我们需要了解一些基本的数学概念。以下是这些概念:
  • Matrices and matrix multiplication
    • 矩阵和矩阵乘法
  • General concept of functions in mathematics
    • 数学中函数的一般概念
  • Raising numbers to powers (e.g. a3 = a*a*a)
    • 将数字提升到幂(例如 a³ = a*a*a)
  • Sample mean, variance, and standard deviation
    • 样本均值、方差和标准差

Self Attention 自注意力

到目前为止,我们只见过一种简单的神经网络结构(称为前馈网络),它包含多个层,每一层与下一层完全连接(即,连续层之间的任何两个神经元之间都有一条连接线),并且只与下一层连接(例如,层 1 和层 3 之间没有连接线等)。当然我们也可以移除或建立其他连接。甚至可以构建更复杂的结构。让我们探索一个特别重要的结构:自注意力。
 
如果你查看人类语言的结构,我们想要预测的下一个词将依赖于之前的所有词。然而,它们可能更依赖于某些前面的词,而不是其他词。例如,如果我们试图预测“富豪有一个私生子,一个女孩,他在遗嘱中写明,所有他的财物,以及魔法珠,都会属于____”。这里的词可以是“她”或“他”,具体依赖于句子中一个更早的词:女孩/男孩. 好消息是,我们的简单前馈模型连接到上下文中的所有单词,因此它可以学习重要单词的适当权重。但问题在于,通过前馈层连接我们模型中特定位置的权重是固定的(对于每个位置)。如果重要单词总是在同一位置,它将适当地学习权重,我们就没问题。然而,下一次预测的相关单词可以在系统中的任何地方。我们可以对上面的句子进行改述,当猜测“她与他”时,对于这个预测,一个非常重要的单词是男孩/女孩,无论它出现在句子的哪个位置。因此,我们需要不仅依赖于位置,还依赖于该位置内容的权重。我们如何实现这一点?
 
自注意力做的事情类似于将每个单词的嵌入向量相加,但它不是直接相加,而是对每个向量应用一些权重。因此,如果“humpty”、“dumpty”、“sat”的嵌入向量分别是 ,那么它会在相加之前将每个向量乘以一个权重(一个数字)。类似于 ,其中 output 是自注意力输出。如果我们将权重写为 ,使得 ,那么我们如何找到这些权重
 
理想情况下,我们希望这些权重依赖于我们正在添加的向量——正如我们看到的,有些可能比其他的更重要。但对谁重要呢?对我们即将预测的单词。因此,我们还希望权重依赖于我们即将预测的单词。现在这是一个问题,当然在我们预测之前我们并不知道即将预测的单词。所以,自注意力使用的是我们即将预测的单词之前的单词,即句子中可用的最后一个单词(我真的不知道为什么是这个而不是其他的,但深度学习中的很多事情都是反复试验,我怀疑这个方法效果很好)
 
很好,所以我们想要这些向量的权重,并且我们希望每个权重依赖于我们正在聚合的单词和我们即将预测的单词之前的单词。基本上,我们想要一个函数 ,其中 是我们将加权的单词, 是我们拥有的序列中的最后一个单词(假设我们只有 3 个单词)。现在,实现这一点的一个简单方法是为 创建一个向量(我们称之为 ),为 创建一个单独的向量(我们称之为 ),然后简单地取它们的点积。这将给我们一个数字,并且它将依赖于 。我们如何获得这些向量 ?我们构建一个小型单层神经网络,从 (或从 ,从 ,依此类推)。然后我们构建另一个网络,从 等等……使用我们的矩阵表示法,我们基本上得出权重矩阵 ,使得 ,依此类推。现在我们可以取 的点积来得到一个标量,因此 。 自注意力中发生的另一件事是,我们并不直接对嵌入向量本身进行加权求和。相反,我们对该嵌入向量计算得到的某个“值”进行加权求和,这个值是通过另一个小的单层网络获得的。这意味着,类似于 ,我们现在也有了单词 ,并且通过矩阵 获得它,使得 。然后对这个 进行聚合。因此,如果我们只有 3 个单词并试图预测第四个单词,它看起来大致是这样的:
notion image
 
加号表示向量的简单相加,这意味着它们必须具有相同的长度。这里没有显示的最后一个修改是标量 等不一定加起来等于 1。如果我们需要它们作为权重,我们应该使它们相加。因此,我们将在这里应用一个熟悉的技巧,使用 函数。
这是自注意力。还有交叉注意力,其中 可以来自最后一个词,但 可以来自另一句话。这在翻译任务中是非常有价值的。现在我们知道什么是注意力。
这个整体现在可以放在一个框里,称为“自注意力块”。基本上,这个自注意力块接收嵌入向量,并输出一个用户选择长度的单一输出向量。这个块有三个参数, ,机器学习文献中有许多这样的块,它们通常在图表中用框表示,并标有它们的名称。像这样:
notion image
 
你会注意到自注意力的一个特点是,目前事物的位置似乎并不相关。我们在各个方面使用相同的 ,因此交换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)之间的数字并保持顺序:
notion image
 
现在,我们为最后一层的每个神经元都有一个介于 0 和 1 之间的数字,我们可以通过将正确的神经元设置为 1,其他设置为 0,并计算与网络提供的结果之间的差异来计算损失。这是可行的,但我们能做得更好吗?
 
回到我们的 “Humpty dumpty”例子,假设我们试图逐个字符生成 dumpty,而我们的模型在预测“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 残差连接

我们现在逐渐对可视化网络进行改变。使用方框/块来表示某些概念。这种符号在表示残差连接这一特别有用的概念时非常有用。让我们来看一下与自注意力块结合的残差连接:
 
notion image
 
请注意,我们将“输入”和“输出”放入框中以简化操作,但这些基本上仍然只是与上面所示相同的一组神经元/数字。
 
那么这里发生了什么?我们基本上是在获取自注意力块的输出,并在将其传递到下一个块之前,将原始输入添加到其中。首先要注意的是,这需要自注意力块的输出维度现在必须与输入的维度相同。这不是问题,因为正如我们所指出的,自注意力输出是由用户决定的。但为什么要这样做呢?我们在这里不会深入所有细节,但关键是随着网络变得更深(输入和输出之间的层数增加),训练它们变得越来越困难。残差连接已被证明有助于解决这些训练挑战。
 

Layer Normalization 层归一化

 
层归一化是一个相当简单的层,它接收进入该层的数据,通过减去均值并除以标准差来进行归一化(可能还有更多,如下所示)。例如,如果我们在输入后立即应用层归一化,它将处理输入层中的所有神经元,然后计算两个统计量:它们的均值和标准差。假设均值为 M,标准差为 S,那么层归一化所做的就是将每个神经元替换为 ,其中 x 表示任何给定神经元的原始值。
 
现在这有什么帮助呢?它基本上稳定了输入向量,并有助于训练深度网络。一个担忧是,通过对输入进行归一化,我们是否去除了其中一些可能对学习我们目标有价值的有用信息?为了解决这个问题,层归一化层有一个缩放和一个偏置参数。基本上,对于每个神经元,你只需将其乘以一个标量,然后加上一个偏置。这些标量和偏置值是可以训练的参数。这使得网络能够学习一些可能对预测有价值的变化。由于这些是唯一的参数,层归一化块没有很多参数需要训练。整个过程看起来像这样:
 
notion image
缩放()和偏差()是可训练的参数。你可以看到层归一化是一个相对简单的模块,其中每个数字仅在逐点上进行操作(在初始均值和标准差计算之后)。这让我们想起了激活层(例如 RELU),主要区别在于这里有一些可训练的参数(尽管比其他层少得多,因为是简单的逐点操作)。
标准差是一个统计测量,用于衡量数值的分散程度,例如,如果所有数值都相同,则可以说标准差为零。如果一般来说,每个数值都远离这些相同数值的均值,那么标准差就会很高。计算一组数字(a1, a2, a3……,假设有 N 个数字)的标准差的公式大致如下:从每个数字中减去均值(这些数字的均值),答案进行平方。将所有这些数字相加,然后除以 N。最后对答案取平方根。
注意事项:经验丰富的机器学习专业人士会注意到这里没有讨论批归一化。实际上,我们在这篇文章中甚至没有引入批次的概念。在大多数情况下,我认为批次是另一种与核心概念理解无关的训练加速器(也许除了我们在这里不需要的批归一化)。

Dropout

丢弃法是一种简单但有效的方法,可以避免模型过拟合。过拟合是指当你在训练数据上训练模型时,它在该数据集上表现良好,但对模型未见过的示例泛化效果不佳。帮助我们避免过拟合的技术被称为“正则化技术”,而丢弃法就是其中之一。
如果训练一个模型,它可能会在数据上出错和/或以特定方式过拟合。如果你训练另一个模型,它可能也会这样,但方式不同。如果你训练多个这样的模型并对输出进行平均呢?这些通常被称为“集成模型”,因为它们通过结合多个模型的输出来预测结果,而集成模型通常比任何单个模型表现更好。
通常情况下,为了提升模型的泛化能力,可以使用集成方法(ensemble),即通过多次预测取平均来获得更稳健的输出。而 Dropout 是一种在训练过程中随机丢弃部分神经元的方法,其目的是避免模型对特定特征的过拟合。这种方法在训练阶段通过随机删除一定比例的权重,使模型在不同训练阶段对特定权重的依赖减弱,从而形成类似集成的效果。
notion image
 
现在,这迫使网络以大量冗余进行训练。实质上相当于同时训练多个不同的模型——但它们共享权重。
在推理(inference)阶段,我们不再随机丢弃神经元,因为这会导致输出不稳定且增加计算量。然而,因为模型是在 50% 权重的情况下训练的,所以直接使用所有权重进行推理时,模型中间神经元的数值可能会偏高,这会影响结果。为了解决这一问题,可以对所有权重乘以一个系数,以近似训练阶段的平均激活情况。具体做法是将每个权重乘以 (1 - p),其中 (p) 是 Dropout 过程中丢弃神经元的概率。
也就是训练时,模型通过随机 Dropout 实现不同神经元组合的训练,推理阶段使用所有权重,并通过 (1 - p) 的权重缩放来恢复模型的输出分布。这种方法在实践中被证明为一种有效的正则化技术,既能利用所有权重的计算能力,又保留了 Dropout 所带来的泛化优势。
 

Multi-head Attention

这是 transformer 架构的关键模块,上面我们已经提到了自注意力。请记住,注意力块的输出的维度是由用户决定的,通常与 v 的长度一致。Multi-head Attention的基本原理是同时运行多个“注意力”头(attention heads),这些头都接收相同的输入。每个头都会独立地执行一次注意力计算,得出自己的输出。然后,我们将所有这些头的输出连接(concatenate)起来,形成一个最终的输出。
notion image
请记住,从 的箭头是线性层——每个箭头上都有一个矩阵进行转换。
在图中,不同的头(head)之间的 h1h2 表示不同的 Self-Attention 头的编号,即 Head 1 和 Head 2。每个 Attention 头有自己独立的查询 (Query)、键 (Key) 和值 (Value) 的线性变换参数,所以我们用 h1h2 来表示这些不同头中的查询、键和值向量。
在 Multi-Head Attention 中,每个头都可以使用不同的线性变换来计算查询、键和值向量,这样可以让每个头学习到输入数据的不同特征。具体来说,Multi-Head Attention 通过以下步骤实现:
  1. 线性变换:每个输入词被线性变换生成多个头的查询、键和值向量(q1h1, k1h1, v1h1 表示第 1 个词在 Head 1 中的查询、键和值,q1h2, k1h2, v1h2 表示第 1 个词在 Head 2 中的查询、键和值,以此类推)。
  1. 并行计算 Self-Attention:每个头独立进行 Self-Attention 计算,即基于自己的查询、键和值向量来计算注意力分数和加权求和。
  1. Concatenate:将所有头的输出拼接起来,并通过一个线性层进行变换,得到最终的 Multi-Head Attention 输出。
h1h2 是 Multi-Head Attention 特有的,它们代表不同的 Attention 头。不同的头可以捕捉输入序列的不同特征,这也是 Multi-Head Attention 的优势之一。

Positional encoding and embedding

 
在学习大模型的位置编码(Positional Encoding)和位置嵌入(Positional Embedding)时,我们需要理解它们的目的和区别。

为什么需要位置信息

在自注意力机制(Self-Attention)中,模型通过关注序列中不同位置的词汇之间的关系来捕捉上下文信息。然而,注意力机制本身不考虑词汇的顺序信息,这对自然语言处理非常重要。为了解决这一问题,我们引入了位置编码或位置嵌入,以便在不改变自注意力机制的情况下给模型提供词汇的位置信息。

位置编码 vs 位置嵌入

  • 位置编码(Positional Encoding):位置编码是一种添加位置信息的方法,通常通过某些特定的数学函数(例如正弦和余弦函数)生成。在 Transformer 原始论文中,位置编码是通过这些周期函数实现的,每个位置生成一组固定的值。这些值被添加到词嵌入上,从而将位置信息融入到词嵌入中。
  • 位置嵌入(Positional Embedding):与位置编码相比,位置嵌入更像普通的词嵌入(Word Embedding)。简单来说,位置嵌入将序列中的每个位置(例如位置1, 2, 3...)映射到一个向量,就像我们将词汇映射到一个词向量一样。位置嵌入实际上是一个矩阵,这个矩阵的大小和词嵌入矩阵相同,每一列对应一个位置,嵌入后的向量会包含位置信息。这样就可以为每个词汇提供一个具体位置的向量,而这些向量将直接传递到模型中。

选择位置嵌入的原因

在许多现代 Transformer 变体中,位置嵌入比位置编码更常用。这是因为通过训练的方式获得的嵌入可以让模型学习到更多的数据驱动的位置信息,相比于固定函数生成的编码可能更有效。

The GPT architecture

 
让我们谈谈 GPT 架构。这是大多数 GPT 模型中使用的架构(各有不同)。
notion image
 
在这一点上,除了“GPT Transformer Block”之外,其他所有模块都已详细讨论。这里的 + 符号仅表示两个向量相加(这意味着两个嵌入必须具有相同的大小)。让我们来看看这个 GPT Transformer Block:
 
notion image
 
让我们回顾一下到目前为止我们所涵盖的所有内容,以构建这个 GPT 架构:
  • 我们看到神经网络如何将数字输入并输出其他数字,并且具有可以训练的权重作为参数
  • 我们可以将这些输入/输出数字附加解释,并赋予神经网络现实世界的意义
  • 我们可以将神经网络串联起来以创建更大的网络,我们可以将每个网络称为“块”,并用一个框表示,以便使图表更易于理解。每个块仍然执行相同的操作,接收一组数字并输出另一组数字。
  • 我们学习了许多不同类型的积木,它们有不同的用途
  • GPT 只是这些块的特殊排列,如上所示,并且我们在第一部分中讨论了其解释
随着公司逐渐发展出强大的现代LLMs,对此进行了多次修改,但基本内容保持不变。现在,这个 GPT Transformer 实际上是在最初介绍Transformer的论文中所称的“解码器(decoder)”。
 

The transformer architecture

这是最近推动语言模型能力快速提升的关键创新之一。Transformer 不仅提高了预测准确性,而且比以前的模型更容易/更高效(进行训练),允许更大的模型规模。这就是上面 GPT 架构的基础。
如果你查看 GPT 架构,你会发现它非常适合生成序列中的下一个词。它基本上遵循我们在第一部分讨论的相同逻辑。从几个词开始,然后继续逐个生成。但是,如果你想进行翻译呢?如果你有一句德语句子(例如:“Wo wohnst du?” = “你住在哪里?”),你想把它翻译成英语。我们将如何训练模型来做到这一点?
好吧,首先我们需要做的是找出一种输入德语单词的方法。这意味着我们必须扩展我们的嵌入,以包括德语和英语。现在,我想这里有一个简单的方法来输入信息。我们为什么不把德语句子连接到目前生成的英语句子的开头,然后将其输入上下文呢?为了让模型更容易处理,我们可以添加一个分隔符。每一步看起来会像这样:
notion image
这是可以的,但还有改进的空间:
  • 如果上下文长度是固定的,有时原句会丢失
  • 模型在这里有很多要学习的。两种语言同时,但也要知道 <SEP> 是需要开始翻译的分隔符。
  • 您正在处理整个德语句子,针对每个单词生成使用不同的偏移量。这意味着同一事物将有不同的内部表示,模型应该能够处理所有这些进行翻译。
 
Transformer 最初是为这个任务创建的,由“编码器”和“解码器”组成——这基本上是两个独立的模块。一个模块简单地接收德语句子并输出一个中间表示(就是一堆数字)——这被称为编码器。
 
Transformer 最初是为这个任务创建的,由“编码器”和“解码器”组成——这基本上是两个独立的模块。一个模块简单地接收德语句子并输出一个中间表示(再次说,就是一堆数字)——这被称为编码器。
第二个模块生成单词(到目前为止我们已经看到了很多)。唯一的区别是,除了输入到目前为止生成的单词外,我们还输入编码后的德语(来自编码器模块)句子。因此,在生成语言时,它的上下文基本上是到目前为止生成的所有单词,加上德语。这个模块被称为解码器。
这些编码器和解码器由几个模块组成,特别是夹在其他层之间的注意力模块。让我们看看论文“Attention is all you need”中的变换器插图,并试着理解它:
notion image
左侧的垂直块称为“编码器”,右侧的块称为“解码器”。让我们回顾一下并理解我们之前未涵盖的内容:
如何阅读图表的回顾:这里的每个框都是一个接收神经元输入的模块,并输出一组神经元作为输出,这些输出可以被下一个模块处理或被我们解释。箭头显示了一个模块的输出去向。如您所见,我们通常会将一个模块的输出作为输入馈送到多个模块中。让我们逐一了解这里的每个内容:

前馈神经网络

前馈网络是指不包含循环的网络。我们在第一节中的原始网络是一个前馈网络。实际上,这个模块使用了非常相似的结构。它包含两个线性层,每个后面跟着一个 RELU(请参见第一节中的 RELU 说明)和一个丢弃层。这种前馈网络在应用时独立于每个输入位置,即每个位置的数据只通过自己的前馈网络进行处理,不会和其他位置的信息互相影响。这样做的原因是为了防止在训练中出现“作弊”现象,即不同位置之间互相泄露信息,使模型在训练时无法看到未来位置的数据,从而保持模型的准确性和公平性。

编码器的输入和输出

  1. 输入(Input):编码器的输入是一个序列,例如在机器翻译任务中,它可以是一个源语言的句子,如英语句子。输入的每个词通常会被转化为向量表示,比如通过词嵌入(word embeddings)或位置编码(positional encoding)得到的嵌入向量,以捕捉每个词的语义和在句子中的相对位置。
  1. 输出(Output):编码器的输出是一个经过多层注意力和前馈神经网络处理后的序列,表示了输入序列中的每个词的编码表示。输出的每个向量都包含了输入序列中其他词的信息,即每个词不仅包含它自身的信息,还编码了它与上下文中其他词的关系。

编码器的作用

编码器的主要作用是将输入序列转化为一个包含上下文和语义关系的高维空间的语义表示。具体作用如下:
  1. 捕捉上下文信息:通过多层的自注意力机制,编码器能够有效捕捉输入序列中各个词之间的长程依赖关系。每一层的自注意力使得每个词可以“关注”到其他相关的词,从而形成丰富的上下文表示。
  1. 生成语义表示:编码器的输出是一组高维向量,这些向量不仅仅包含单词的基本语义信息,还包含它们在整个输入序列中的语义关系。这种表示方式能够让解码器在生成输出时利用源语言的上下文来更准确地生成目标序列。
  1. 作为解码器的输入:在 Transformer 模型中,解码器会使用编码器的输出作为 Cross-Attention 的输入。这种输入能让解码器在生成每个输出词时参考源序列的语义信息,从而帮助模型生成更加准确和上下文相关的输出。
 
在了解 Cross-Attention 之前,先回顾一下 Self-Attention 和 Multi-Head Attention 的基本概念:
  1. Self-Attention: 在自注意力(Self-Attention)中,同一序列的每一个单词都会对整个序列中的其他单词产生依赖。这是通过计算序列中的每一个单词(Query)和其他单词(Key)的相关性(即注意力权重)来实现的。之后,这些权重会被用来对其他单词的值(Value)进行加权求和,得到这个单词在该上下文中的表示。
  1. Multi-Head Attention: 多头注意力(Multi-Head Attention)是将输入序列进行多次不同的线性变换,形成多个头,每个头都计算自己的一组 Query、Key 和 Value。这样做的好处是,模型可以同时关注序列中的多个不同方面,增强了模型的表达能力。

Cross-Attention 的基本概念

在 Transformer 的结构中,Cross-Attention 通常出现在解码器中。具体来说,它是一个接受编码器输出作为参数的多头注意力机制。下面是 Cross-Attention 的工作原理:
  • Cross-Attention 的独特之处在于,查询(Query)仍然来自解码器中解码序列的最后一个词,而键(Key)和值(Value)则来自于编码器的输出序列。
  • 不同来源的键和值:在 Self-Attention 中,查询、键和值全部来自同一个序列。而在 Cross-Attention 中,查询(Query)依然来自解码器当前状态(通常是解码序列的最后一个词),但键和值来自编码器的输出。这种机制使得解码器能够“查看”编码器所编码的信息,从而能够利用编码序列中的上下文信息来生成更加准确的输出。
  • 数学上没有改变:从数学的角度来看,Cross-Attention 与 Self-Attention 没有区别——还是计算每个查询和键的点积来获得注意力权重,再用这些权重对值进行加权求和。但是,键和值的来源不同,这是 Cross-Attention 和 Self-Attention 的关键区别。
Cross-Attention 使得解码器可以在解码每一个词时,不仅依赖于它自身的序列,还能参考编码器所学到的源序列的上下文。这在翻译、文本生成等任务中尤为重要,因为目标语言的句子结构和语义信息往往依赖于源语言的句子结构和上下文。

Appendix 附录

Matrix Multiplication 矩阵乘法

我们在嵌入的上下文中介绍了向量和矩阵。矩阵有两个维度(行数和列数)。向量也可以被视为一个矩阵,其中一个维度等于一。两个矩阵的乘积定义为:
notion image
点表示乘法。现在让我们再看一看第一张图片中权重和输入层神经元的计算。如果我们将权重写成矩阵,将输入写成向量,我们可以以以下方式写出整个操作:
notion image
如果权重矩阵称为“W”,输入称为“x”,那么 Wx 就是结果(在这种情况下是中间层)。我们也可以将两者转置,写成 xW——这只是个人偏好问题。

Standard deviation 标准差

我们在层归一化部分使用标准差的概念。标准差是一个统计量,用于衡量数值的分散程度(在一组数字中),例如,如果所有值都相同,则可以说标准差为零。如果一般来说,每个值都远离这些相同值的均值,那么标准差就会很高。计算一组数字(a1,a2,a3……,假设有 N 个数字)的标准差的公式大致如下:从每个数字中减去均值(这些数字的均值),然后对每个差值进行平方。将所有这些结果相加,然后除以 N。最后对答案取平方根。
notion image

Positional Encoding 位置编码

我们在上面讨论了位置嵌入。位置编码只是一个与词嵌入向量长度相同的向量,但它不是嵌入,因为它不是经过训练的。我们简单地为每个位置分配一个唯一的向量,例如,位置 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 条正弦曲线,每条为 (即 10k 的 i/d 次方)
  • 用数字填充编码矩阵,使得(i,p)位置的数字为 si(p),例如,对于位置 1,编码向量的第 5 个元素为
为什么选择这种方法?通过在 10k 上改变功率,您正在改变在 p 轴上查看时正弦函数的幅度。如果您有 10 个不同幅度的正弦函数,那么在改变 p 值时,您将很长时间才会得到重复(即所有 10 个值相同)。这有助于我们获得唯一的值。现在,实际的论文使用了正弦和余弦函数,编码形式为:si(p) = sin (p/10000(i/d)) 如果 i 是偶数,si(p) = cos(p/10000(i/d)) 如果 i 是奇数
 
 
上一篇
MCP协议
下一篇
大模型概念扫盲