在本文中,我们将从零开始探讨大型语言模型(LLMs)的工作原理——只假设你会做两个数的加法和乘法。文章力求完全独立。我们先在纸上构建一个简单的生成式 AI,然后一步步深入,让你牢固掌握现代 LLM 及其 Transformer 架构。文章会剥去机器学习里所有花哨的术语和行话,把一切用最简单的方式呈现:数字。我们仍会指出各个部分的正式名称,以便你在阅读专业内容时能迅速对应。
==从加减乘除走到当今最先进的 AI 模型,且不依赖其他知识或外部资料,意味着我们要跨越巨大范围== 。这不是玩具级 LLM 的讲解——一个执着的人理论上能凭借本文所有信息复现现代 LLM。我已删去所有不必要的词句,因此本文并不适合随便浏览。
让我们开始吧。
首先要注意,神经网络只能接受数字作为输入,也只能输出数字,没有例外。关键在于:如何把输入转化为数字、如何解读输出的数字以实现目标,以及如何构建神经网络,使它接受你提供的输入,并给出你想要的输出(按照你对输出的解读方式)。让我们一步步看看,从简单的加减乘除,是如何发展到像 Llama 3.1[1] 这样的模型的。
让我们来构建一个简单的神经网络,用于对物体进行分类:
以下是叶子与向日葵的数据示例:
==现在我们来构建一个完成这种分类任务的神经网络。我们需要先确定输入和输出的表示方式。我们的输入已经是数值,因此可以直接喂给网络。输出是“叶子”和“花”两个类别,而神经网络无法直接输出这些对象。让我们看看可以采用的几种方案== :
两种方案都能让网络输出可供我们解释为“叶子”或“花”的数值。我们选择第二种方案,因为它在后续要讨论的其他任务中也具有良好的通用性。下面是一个采用这种方案进行分类的神经网络。让我们一步步来看:
一些术语:
神经元/节点 :圆圈里的数字
权重 :线条上带颜色的数字
层 :一组神经元称为一层。你可以把这个网络看作是三层:输入层有 4 个神经元,中间层有 3 个神经元,输出层有 2 个神经元。
要计算这个网络的预测/输出(称为“ 前向传播 ”),从左侧开始。我们已经获得了输入层神经元的数据。为了“向前”进入下一层,你需要将圆圈中的数字与对应神经元配对的权重相乘,然后把它们全部加起来。 ==我们在上方用蓝色和橙色圆圈演示了计算过程。== 运行整个网络后,我们看到输出层的第一个数字更大,因此将其解释为“网络将这些 (RGB,Vol) 值分类为叶子”。一个训练良好的网络可以接收各种不同的 (RGB,Vol) 输入,并正确地对物体进行分类。
模型并不知道叶子或花朵是什么,也不知道 (RGB,Vol) 代表什么。它的任务仅仅是接收恰好 4 个数字,然后输出恰好 2 个数字。我们把 4 个输入数字解释为 (RGB,Vol) 是我们自己的决定;我们查看输出数字并推断如果第一个数字更大就是叶子,诸如此类,也是我们自己的决定。最终,选择恰当的权重,让模型接收我们的输入数字并给出正确的两个数字,以便我们解释它们时得到想要的结论,同样取决于我们自己。
一个有趣的副作用是,你可以使用同一个网络,但不输入 RGB 和 Vol,而是输入其他 4 个数字,比如云量、湿度等,并把两个输出数字解释成“一小时后晴天”或“一小时后下雨”。如果权重校准得当,你就能让同一个网络同时完成两项任务——区分叶子/花朵并预测一小时后是否会下雨!网络只是给出两个数字,至于你把它们解释为分类、预测还是别的什么,完全取决于你。
为简化而省略的内容(可忽略,不影响理解):
在上面的例子中,我们神奇地拥有了权重,能把数据输入模型并得到良好的输出。但这些权重是如何确定的呢?设定这些权重(或“参数”)的过程称为“ 训练模型 ”,我们需要一些训练数据来训练模型。
假设我们有一些数据,其中包括输入值,并且我们已经知道每个输入对应的是叶子还是花,这就是我们的“ 训练数据 ”;由于我们为每组 (R,G,B,Vol) 数值都提供了叶子/花的标签,因此这属于“ 带标签的数据 ”。
工作原理如下:
几点补充:
在实践中,训练深层网络是一个困难且复杂的过程,因为梯度很容易失控,在训练中变为零或无穷大(称为“梯度消失”和“梯度爆炸”问题)。我们前面提到的损失定义完全有效,但实际很少使用,因为存在针对特定目的表现更好的函数形式。现代模型拥有数十亿参数,训练模型需要巨大的计算资源,这本身也带来了问题(内存限制、并行化等)。
记住,神经网络接收一些数字,根据训练好的参数进行计算,然后输出另一些数字。一切都取决于如何解释这些数字,以及如何训练参数(即把它们设为某些数值)。如果我们能把两个数字解释为“叶子/花”或“一小时后的雨或晴”,我们也能把它们解释为“句子中的下一个字符”。
但英语字母不止2个,所以我们必须把输出层的神经元数量扩展到,比如英语字母的26个(再额外加上空格、句号等符号)。每个神经元对应一个字符,我们查看输出层的这26个左右神经元,把数值最高的那个神经元对应的字符作为输出字符。现在我们有了一个网络,接收一些输入并输出一个字符。
如果我们把网络的输入换成这些字符:“Humpty Dumpt”,然后让它输出一个字符,并将这个字符解释为“网络对我们刚刚输入序列的下一个字符的建议”。我们大概可以把权重调得足够好,让它输出“y”——从而补全“Humpty Dumpty”。但有一个问题:我们怎么把这些字符列表输入到网络里?网络只接受数字!!
一个简单的解决办法是给每个字符分配一个数字。假设 a=1,b=2,以此类推。现在我们可以输入“humpty dumpt”,并训练它输出“y”。我们的网络看起来像这样:
Image by author
现在,我们可以向网络提供一串字符,从而预测下一个字符。利用这一点,我们就能构建整个句子。例如,一旦预测出了“y”,就可以把“y”追加到现有字符列表里,再喂给网络,让它预测下一个字符。如果训练得当,它应当会生成一个空格,依此类推。最终,我们就能递归地生成“Humpty Dumpty sat on a wall”。我们拥有了生成式 AI。更进一步, 我们现在拥有了一个能够生成语言的网络! 当然,没人真的会去用随机赋值的数字,稍后我们会看到更合理的方案。如果你等不及,可以直接查看附录中关于 one-hot 编码的部分。
敏锐的读者会注意到,我们其实无法把“Humpty Dumpty”直接输入网络,因为按图示,输入层只有 12 个神经元,每个对应“humpty dumpt”中的一个字符(包括空格)。那下一轮怎么把“y”喂进去?再增加第 13 个神经元就得改动整个网络,显然行不通。办法很简单:把最前面的“h”踢出去,只保留最近的 12 个字符。于是我们会输入“umpty dumpty”,网络预测出空格;接着输入“mpty dumpty ”,它会输出一个“s”,依此类推。大致流程如下:
Image by author
我们在最后一行只喂给模型“ sat on the wal”时,实际上丢弃了大量信息。那么今天最先进、最出色的网络是怎么做的呢?差不多就是那样。我们能输入网络的长度是固定的(由输入层的大小决定)。这被称为“上下文长度”——提供给网络以进行未来预测的上下文。现代网络可以拥有非常大的上下文长度(几千个词),这很有帮助。确实存在一些输入无限长序列的方法,但这些方法的性能虽然令人印象深刻,后来还是被拥有大(但固定)上下文长度的其他模型超越了。
细心的读者还会注意到,我们对同一字母的输入和输出采用了不同的解释方式!例如,输入 “h” 时,我们只用数字 8 来表示它;但在输出层,我们并不让模型直接输出单个数字(“h” 为 8,“i” 为 9,以此类推),而是让模型输出 26 个数字,然后看哪一个最大。如果第 8 个数字最大,我们就把输出解释为 “h”。为什么两端不采用统一、一致的解释呢?其实是可以的,只不过在语言任务中,允许自己灵活选择不同的解释方式,往往更有机会构建出更优秀的模型。恰好,目前最有效的输入解释和输出解释并不相同。事实上,我们在这个模型里把数字当作输入的方式并不是最优的,稍后我们会探讨更好的做法。
逐个字符地生成“Humpty Dumpty sat on a wall”与现代 LLMs 所能做到的相去甚远。从上面讨论的简单生成式 AI 到类人机器人,有一系列差异与创新。让我们逐一了解它们:
记得我们说过,将字符输入模型的方式并不是最好的方法。我们只是为每个字符随意挑选了一个数字。如果存在更好的数字可以分配,让我们能够训练出更优秀的网络,那该怎么办?我们如何找到这些更好的数字?这里有一个巧妙的技巧:
当我们训练上述模型时,我们的做法是通过调整权重,看看最终是否能得到更小的损失。然后缓慢且递归地改变权重。在每一步中,我们会:
在此过程中,输入是固定的。当输入是 (RGB, Vol) 时这很合理。但现在我们填入的 a、b、c 等数字是我们随意挑选的。如果每次迭代时,除了稍微调整权重,我们也调整一下输入,看看能否用不同的数字来表示 “a” 等来获得更低的损失呢?我们确实在降低损失、优化模型(这是我们特意为 a 的输入所调整的方向)。本质上,不仅对权重做梯度下降,也对输入的数值表示做梯度下降,反正它们只是随意挑的数字。这就叫“ ==嵌入== ”。它是输入到数字的映射,正如你刚才看到的,它需要训练。训练嵌入的过程与训练参数非常相似。一个很大的优势是,一旦训练好嵌入,你可以把它用在另一个模型里。请记住,对于同一个 token/字符/单词,你会始终使用同一个嵌入来表示。
我们之前讨论的嵌入是“每个字符只有一个数字”。然而,现实中的嵌入包含不止一个数字。原因在于,用一个数字很难表达概念的丰富性。回头看看叶子和花朵的例子,我们为每个对象准备了四个数字(输入层的大小)。这四个数字各自传递一种属性,模型能够综合使用它们来有效猜测对象。如果我们只有一个数字,比如颜色的红色通道,模型可能就会困难得多。我们要捕捉的是人类语言——显然需要的不止一个数字。
==与其用单个数字表示每个字符,也许我们可以用多个数字来捕捉其丰富信息?== 让我们给每个字符分配一组数字。我们把一组有序的数字称为“向量”(有序指的是每个数字都有一个位置,如果我们交换两个数字的位置,就会得到一个不同的向量。就像我们对叶子/花朵数据所做的那样,如果交换叶子的 R 和 G 数字,我们就会得到不同的颜色,它就不再是同一个向量了)。向量的长度就是它包含的数字个数。我们将为每个字符分配一个向量。于是产生了两个问题:
所有嵌入向量当然必须长度相同,否则我们就无法把所有字符组合输入网络。例如 “humpty dumpt” 和下一轮迭代中的 “umpty dumpty”——在这两种情况下,我们都向网络输入 12 个字符,如果这 12 个字符中的每一个不是用长度为 10 的向量来表示,我们就无法可靠地把它们全部喂给一个 120 长度的输入层。让我们把这些嵌入向量可视化:
Image by author
我们把一组大小相同的向量按顺序排列起来称为矩阵。上面这个矩阵叫做嵌入矩阵 。你告诉它一个对应字母的列号,它就把矩阵中该列的向量返回给你,这个向量就是你用来表示那个字母的。这种方法可以更广泛地用于嵌入任意一组事物——只需让矩阵的列数等于事物的数量即可。
到目前为止,我们一直把字符当作语言的基本单元。这有其局限。神经网络的权重必须承担大量工作,去理解某些字符序列(即单词)彼此相邻出现,再与其他单词相邻出现。如果我们直接把嵌入向量赋给单词,让网络预测下一个单词会怎样?反正网络只认识数字,因此我们可以给“humpty”“dumpty”“sat”“on”等每个词都分配一个长度为 10 的向量,然后只需喂给它两个词,它就能给出下一个词。“Token”指的是我们嵌入并喂给模型的最小单元。此前的模型用字符作 token,现在我们提议把整个单词当作 token(当然,如果你喜欢,也可以把整句或短语当作 token)。
使用词级分词对我们的模型产生了一个深远影响:英语词汇量超过 18 万个。按照我们为每个可能的输出都设置一个神经元的输出解释方案,输出层需要数十万个神经元,而不是 26 个左右。对于现代网络,所需的隐藏层规模已足够大,这一问题就显得不那么紧迫了。然而值得注意的是,由于我们将每个词单独处理,并为每个词随机初始化嵌入向量,非常相似的词(如 “cat” 与 “cats”)在一开始并不会存在任何关系。你可能会期望这两个词的嵌入向量应该彼此接近——模型最终无疑会学到这一点。但能否利用这种显而易见的相似性,让模型有一个更好的起点,从而简化整个流程呢?
是的,我们可以。如今语言模型中最常见的嵌入方案是将单词拆分为子词,然后对子词进行嵌入。在前面的“cat”示例中,我们会把“cats”拆成两个 token:“cat”和“s”。这样模型就更容易理解“s”后面跟着其他熟悉单词等概念。这也减少了我们需要的 token 数量(sentencepiece[2] 是一种常见的分词器,其词汇表大小可选几万,而英语单词总数可达几十万)。 ==分词器会将你输入的文本(例如 “Humpty Dumpt”)拆分成一个个 token,并给出对应的数字,以便你在嵌入矩阵中查找该 token 的嵌入向量。== 例如,对于 “humpty dumpty”,如果我们使用字符级分词器,并且按照上图那样排列嵌入矩阵,那么分词器会先把 humpty dumpt 拆成字符 [‘h’,’u’,…’t’],然后返回数字 [8,21,…20],因为需要查询嵌入矩阵的第 8 列才能得到 ‘h’ 的嵌入向量(嵌入向量才是喂给模型的内容,而不是数字 8,与之前不同)。矩阵里列的顺序完全无关紧要,我们可以把任意一列指派给 ‘h’,只要每次输入 ‘h’ 都查找同一向量即可。分词器只是给我们一个任意但固定的数字,方便查找。它们真正的主要任务是把句子拆分成 token。
有了嵌入和子词分词,模型大致可以长这样:
接下来的几节将介绍语言建模的最新进展,以及使 LLMs 达到今天如此强大水平的关键技术。然而,为了理解这些内容,你需要掌握一些基本的数学概念。以下是这些概念:
我在附录中添加了这些概念的总结。
到目前为止,我们只见过一种简单的神经网络结构(称为前馈网络),它包含若干层,每一层都与下一层全连接(即,任意两个连续层中的神经元之间都用线条相连),并且仅与下一层相连(例如,第一层与第三层之间没有线条)。然而,正如你所想象的,我们完全可以移除某些连接或创建其他连接,甚至构建更复杂的结构。让我们探讨一个特别重要的结构:自注意力。
如果你观察人类语言的结构,我们想预测的下一个词会依赖于它之前的所有词。然而,它可能对其中某些词依赖程度更高。例如,当我们试图预测“Damian 有一个秘密的孩子,一个女孩,他在遗嘱中写明他所有的财产,连同那颗魔法球,将属于____”中的下一个词时,这个词可能是“her”或“him”,而它具体取决于句子中更早的一个词:_girl/boy_。
好消息是,我们简单的前馈模型会与上下文中的所有词连接,因此它可以学习重要词的适当权重。但问题在于,通过前馈层连接模型中特定位置的权重是固定的(对每个位置都一样)。如果重要词总是在同一个位置,模型就能正确学习权重,我们也就没问题了。然而,与下一个预测相关的词可能出现在系统的任何位置。我们可以改写上面的句子,在猜测“her 还是 his”时,一个非常关键的词是 boy/girl,无论它出现在句子的哪个位置。因此,我们需要权重不仅取决于位置,还取决于该位置的内容。我们该如何实现这一点呢?
自注意力机制有点像把每个词的嵌入向量加起来,但它并不是直接相加,而是先给每个向量乘上一个权重。如果“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 矩阵,也没有应用掩码等操作。这是因为这些细节源自模型常见的训练方式:一次性喂入一批数据,并同时训练模型去预测“dumpty”来自“humpty”、“sat”来自“humpty dumpty”等等。这属于提升效率的手段,并不影响对模型的理解,也不影响最终输出,因此我们在此省略了这些训练优化技巧。
我们在第一篇笔记里简单提过 softmax。softmax 试图解决的问题是:在输出解释中,我们有与可供网络选择选项数量相同的神经元。我们说过,把网络的选择解释为值最高的那个神经元。接着,我们打算把损失计算为网络给出的值与我们期望的理想值之间的差值。可那个理想值到底该是多少?在叶子/花朵示例里我们把它设为 0.8。但为什么是 0.8?为什么不是 5、10 或 1000 万?对于该训练样本,值越高越好。理想情况下,我们希望那里是无限大!可这会使问题无法处理——所有损失都会是无限大,我们“通过调整参数来最小化损失”的计划(还记得“梯度下降”吗)就失效了。我们该如何处理?
我们可以做的一件简单事情,就是给数值设一个上限。假设在 0 和 1 之间?这样所有损失都会变成有限值,但现在出现了一个新问题:如果网络输出“过头”了怎么办?假设某次它对 (叶子, 花) 的输出是 (5,1),另一次是 (0,1)。第一次虽然做出了正确选择,但损失反而更大!好,那现在我们需要一种方法,把最后一层的输出也转换为 0 到 1 的范围,同时保持原有顺序。我们可以用任何函数 (数学里“函数”就是把一个数映射到另一个数——输入一个数,输出另一个数——根据输入按规则给出输出)来完成这件事。一个可选方案是逻辑函数(见下图),它把所有实数映射到 (0,1) 之间并保留顺序:
现在,最后一层的每个神经元都有一个 0 到 1 之间的输出值;我们可以通过把正确神经元设为 1、其余设为 0,再与网络给出的值做差来计算损失。这可行,但还能做得更好吗?
回到“Humpty 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。
随着章节的推进,我们逐渐改变了网络的图示方式。现在用方框/模块来表示某些概念。这种符号在表达“残差连接”这一特别有用的概念时非常方便。让我们看看残差连接与自注意力模块的结合:
残差连接。
请注意,我们为了方便把“输入”和“输出”画成方框,但它们本质上仍是与上图所示相同的一组神经元/数值。
那么这里发生了什么?我们基本上是在将自注意力模块的输出,在送入下一个模块之前,加上最初的输入。首先要说明的是,这就要求自注意力模块的输出维度必须与输入维度相同。这不是问题,因为我们前面提到,自注意力的输出由用户决定。但为什么要这么做?这里不展开所有细节,关键点在于:随着网络变深(输入与输出之间的层数增加),训练会变得越来越困难。残差连接已被证明能够缓解这些训练难题。
层归一化是一个相当简单的层,它接收进入该层的数据,通过减去均值并除以标准差来对其进行归一化(可能稍复杂一些,如下所示)。例如,如果我们在输入之后立即应用层归一化,它会选取输入层中的所有神经元,然后计算两个统计量:它们的均值和标准差。假设均值为 M,标准差为 D,那么层归一化所做的就是对每个神经元,将其值替换为 (x-M)/D,其中 x 表示任意给定神经元的原始值。
那么这有什么好处呢?它基本上稳定了输入向量,并有助于训练深层网络。有人担心,通过对输入进行归一化,是否会把一些对我们学习目标有价值的信息去掉?为了解决这个问题,层归一化层引入了缩放(scale)和偏移(bias)参数。简单来说,对每个神经元,只需乘以一个标量再加上一个偏移即可。这些标量和偏移值都是可训练的参数。这让网络能够学习到可能对预测有用的某些变化。而正因为只有这些参数,LayerNorm 模块需要训练的参数量非常少。整体结构大致如下:
层归一化。
缩放和偏移是可训练参数。可以看到,层归一化是一个非常简单的模块,每个数值在最初的均值和标准差计算之后,都只是逐点操作。这让我们联想到激活层(例如 ReLU),关键区别在于这里有一些可训练参数(不过由于只是简单的逐点操作,参数量比其他层少得多)。
标准差是一种衡量数据分散程度的统计量,例如,如果所有数值都相同,那么标准差就是零。如果每个数值通常都离这些数值的均值非常远,那么标准差就会很大。计算一组数值 a1、a2、a3……(假设有 N 个数)的标准差的公式大致如下:先求出这些数的均值,然后用每个数减去这个均值,再对 N 个数分别求差的平方。把这些平方值加起来,再除以 N,最后对结果取平方根。
预先提醒:有经验的机器学习从业者会注意到,这里没有讨论批归一化(batch norm)。事实上,本文甚至完全没有引入“批次”这一概念。我认为,大多数情况下,批次只是另一种加速训练的手段,与核心概念的理解无关(或许批归一化除外,但我们在这里并不需要它)。
Dropout 是一种简单却有效的方法,用来避免模型过拟合。过拟合指的是模型在训练数据上表现良好,但在未见过的示例上却无法泛化。帮助我们避免过拟合的技术被称为“ 正则化技术 ”,而 dropout 就是其中之一。
当你训练一个模型时,它可能在数据上犯错,或以特定方式过拟合。如果你再训练另一个模型,它可能也会犯错,但方式不同。如果你训练了若干个这样的模型,并将它们的输出平均会怎样?这些通常被称为“ 集成****模型 ”,因为它们通过组合一组模型的输出来进行预测,而集成模型通常比任何单个模型表现更好。
在神经网络中,你也可以这样做。你可以构建多个(略有不同的)模型,然后组合它们的输出来得到一个更好的模型。然而,这在计算上可能非常昂贵。Dropout 并不是真正构建集成模型,但它抓住了这一概念的部分精髓。
这个思路很简单:在训练期间插入一个 dropout 层,你实际上是在随机删除插入位置前后两层之间一定比例的直接神经元连接。以我们最初的网络为例,如果在输入层和中间层之间插入一个 dropout 率为 50% 的 Dropout 层,效果大致如下:
这样一来,网络被迫在大量冗余条件下进行训练。本质上,你同时训练了许多不同的模型——但它们共享权重。
进行推理时,我们可以像集成模型那样操作:用 dropout 多次预测后再合并结果。然而,这种做法计算开销巨大;既然这些模型共享权重,为什么不直接一次性使用全部权重进行预测呢(即不像训练时每次只用 50% 权重,而是同时用全部权重)?这样做应该能近似达到集成的效果。
不过有一个问题:用 50% 权重训练的模型,其中间神经元的数值会与使用全部权重的模型差异很大。我们想要的是更像集成式的平均。那该怎么做?其实很简单:既然现在用了两倍数量的权重,就把全部权重直接乘以 0.5。这就是 Dropout 在推理阶段的做法:它会用包含全部权重的完整网络,然后把所有权重乘以 (1 - p),其中 p 是丢弃概率。事实证明,这是一种非常有效的正则化手段。
这是 Transformer 架构中的核心模块。我们已经了解注意力模块是什么了。记得吧,注意力模块的输出由用户决定,长度与 v 相同。所谓多头注意力,就是并行运行多个注意力头(它们都接收相同的输入),然后把它们的所有输出简单地拼接起来。大致如下:
多头注意力。
请记住,从 v1 -> v1h1 的箭头都是线性层——每个箭头上都有一个矩阵进行变换。我只是为了不显得太乱而没有把它们画出来。
这里所做的,是为每个头生成相同的 key、query 和 value。接着,我们会在使用这些 k、q、v 值之前,对它们分别、且分别针对每个头,再施加一次线性变换。这个额外的层在自注意力中是不存在的。
顺带一提,对我来说,这是构造多头注意力的一种略显出乎意料的方式。例如,为什么不直接为每个头分别创建独立的 Wk、Wq、Wv 矩阵,而是再加一层并共享这些权重?如果你知道原因,请告诉我——我真的毫无头绪。
我们在自注意力部分简要提到了使用位置编码的动机。它们到底是什么?虽然图中展示了位置编码,但位置嵌入比位置编码更常用。因此,这里我们讨论一种常见的位置嵌入,附录也涵盖了原始论文中使用的位置编码。位置嵌入与其他嵌入并无二致,唯一的区别是我们不再嵌入词表,而是嵌入数字 1、2、3 等。因此,这个嵌入是一个与词嵌入长度相同的矩阵,每一列对应一个数字。就这么简单。
让我们来谈谈 GPT 架构。大多数 GPT 模型都使用这一架构(在不同模型间存在变化)。如果你一直跟着文章看到这里,这应该很容易理解。用框图表示,高层架构看起来如下:
GPT 架构。
到目前为止,除了“GPT Transformer Block”之外,其他所有模块都已详细讨论过。这里的“+”号仅表示两个向量相加(因此这两个嵌入必须具有相同维度)。让我们看看这个 GPT Transformer Block:
基本上就是这样。它之所以在这里被称作“transformer”,是因为它源自并属于 transformer 的一种变体——我们将在下一节介绍这一架构。这并不影响理解,因为我们已经讨论过此处展示的所有构建模块。让我们回顾一下到目前为止为构建这一 GPT 架构所涵盖的全部内容:
随着时间的推移,各公司在打造强大现代 LLMs 的过程中对这一结构进行了修改,但其基本思想保持不变。
现在,这个 GPT Transformer 实际上就是原 Transformer 论文中所谓的“解码器”,这篇论文首次提出了 Transformer 架构。我们来看看。
这是近年来推动语言模型能力快速跃升的关键创新之一。Transformer 不仅提升了预测精度,而且比之前的模型更易于训练、更高效,从而支持更大的模型规模。上述 GPT 架构正是基于它构建的。
如果你观察 GPT 架构,就会发现它非常适合在一句话里“生成下一个词”。其核心逻辑与我们在第一部分讨论的相同:先有几句话,然后逐个继续生成。但如果你想做翻译呢?比如你有一句德语句子(例如 “Wo wohnst du?” = “你住在哪里?”),你想把它翻译成英语。我们该如何训练模型来完成这项任务?
那么,我们首先需要想办法输入德语单词。这意味着我们必须扩展我们的嵌入,使其同时包含德语和英语。现在,我想这里有一种简单的输入信息方式。我们为什么不直接把德语句子拼接到目前为止生成的英语内容前面,然后把它送进上下文里呢?为了让模型更容易处理,我们可以加一个分隔符。每一步看起来会是这样:
这样可以工作,但还有改进空间:
Transformer 最初就是为这项任务设计的,由“编码器”和“解码器”组成——本质上是两个独立的模块。一个模块只负责接收德语句子并输出中间表示(同样,基本上是一堆数字)——这就是编码器。
第二个模块负责生成词语(我们前面已经见过很多)。唯一的区别是,除了把已生成的词语喂给它,我们还会把德语句子经过编码后的结果(来自编码器模块)也喂给它。因此,在生成语言时,它的上下文基本上包括到目前为止已生成的所有词语,再加上德语句子。这个模块称为解码器。
每个编码器和解码器都由若干模块组成,其中最为关键的是夹在其它层之间的注意力模块。让我们先看看论文《Attention is all you need》中 Transformer 的示意图,并尝试理解它:
左侧纵向排列的一组模块被称为“编码器”,右侧的则被称为“解码器”。接下来,让我们逐一梳理其中尚未介绍过的部分:
如何阅读示意图回顾 图中的每个方框都是一个模块,它以神经元的形式接收输入,并输出一组神经元。这些输出随后可被下一个模块处理,或供我们解读。箭头指示了模块输出的去向。可以看到,我们经常会把一个模块的输出同时作为多个模块的输入。下面,我们逐一细看:
前馈:前馈网络是不包含环路的网络。我们在第 1 节中的原始网络就是一个前馈网络。实际上,这里的模块采用了几乎完全相同的结构:它包含两个线性层,每层后都跟着一个 ReLU(参见第一节关于 ReLU 的说明)和一个 dropout 层。请记住,这个前馈网络对每个位置都是独立应用的。这意味着位置 0 有一个前馈网络,位置 1 也有一个,依此类推……但位置 x 的神经元与位置 y 的前馈网络没有连接。这一点很重要,因为如果不这么做,网络在训练时就会通过“向前看”而作弊。
交叉注意力: 你会注意到,解码器有一个多头注意力,并且有箭头从编码器伸过来。这是怎么回事?还记得自注意力和多头注意力中的 value、key、query 吗?它们都来自同一个序列。实际上,query 只是来自该序列的最后一个词。那么,如果我们保留 query,但从一个完全不同的序列中获取 value 和 key 呢?这正是现在发生的事情。value 和 key 来自编码器的输出。数学上没有任何变化,只是 key 和 value 的输入来源变了。
_Nx_:这里的 Nx 仅表示该模块被链式重复 N 次。也就是说,你把模块一个接一个地堆叠,把前一个模块的输出作为下一个模块的输入。这是让神经网络变得更深的一种方式。现在,看图时可能会对编码器输出如何送入解码器产生疑惑。假设 N=5,我们是否要把每一个编码器层的输出都送到对应的解码器层?不是的。实际上,你只需一次性完整地跑完整个编码器,然后把这个表示拿来,原封不动地送入全部 5 个解码器层。
Add & Norm 模块 :这基本上和下面的相同(作者大概只是为了节省空间)
作者配图
其他内容都已讨论完毕。现在你拥有了一个完整的 Transformer 架构解释,它从简单的加法与乘法运算出发,完全自成体系!你知道每一行、每一个求和、每一个框和每一个词,在“如何从零开始实现”这一意义上的含义。理论上,这些笔记已经包含了从头编写 Transformer 所需的全部信息。事实上,如果你感兴趣, 这个仓库[3] 就基于上文提到的 GPT 架构实现了它。
至此,我们已经具备了设计和训练一个 LLM 所需的全部组件。让我们把这些部件组合起来,构建一个英语语言模型:
我们在介绍嵌入时引入了向量和矩阵的概念。矩阵有两个维度(行数和列数)。向量也可以被看作是一个矩阵,只是其中一个维度为 1。两个矩阵的乘积定义为:
圆点表示乘法运算。现在,让我们重新审视第一张图中蓝色和有机神经元的计算过程。如果我们将权重写成一个矩阵,将输入写成向量,就可以用以下方式表示整个运算:
如果权重矩阵叫做“W”,输入叫做“x”,那么 Wx 就是结果(在这里指中间层)。我们也可以把两者转置,写成 xW——这纯属个人偏好。
==我们在“层归一化”一节会用到标准差这个概念。标准差是一个统计量,用来衡量一组数值的离散程度;例如,如果所有数值都一样,标准差就是 0。如果每个数值通常都离这组数值的均值很远,标准差就会很大。计算一组数值 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(即输入网络用于预测下一个 token/word 的最大 token/词数,参见“它是如何生成语言的?”一节中的讨论),而嵌入向量的长度为 10(举例),那么我们就需要一个 10×d 的矩阵,其中所有列互不相同,且所有数值都落在 0 到 1 之间。既然 0 和 1 之间有无穷多个数,而矩阵大小有限,这件事可以有无数种做法。
《Attention is all you need》论文里的做法大致如下:
为什么选择这种方法?通过改变 10k 的幂,你就在 p 轴上改变了正弦函数的振幅。而如果有 10 条振幅各不相同的正弦函数,那么在 p 不断变化的情况下,要再次出现重复(即 10 个值全部相同)就需要很长时间。这有助于我们得到唯一的值。实际上,论文同时使用了正弦和余弦函数,其编码形式为:当 i 为偶数时,si(p) = sin(p/10000^(i/d));当 i 为奇数时,si(p) = cos(p/10000^(i/d))。
来源:https://medium.com/data-science/understanding-llms-from-scratch-using-middle-school-math-e602d27ec876
参考资料
[1]
Llama 3.1: https://ai.meta.com/blog/meta-llama-3-1/
[2]
sentencepiece: https://github.com/google/sentencepiece
[3]
这个仓库: https://github.com/karpathy/nanoGPT
[4]
Common Crawl: https://commoncrawl.org/