1. 背景介绍
先前我们通过多篇技术文章来分析大模型的原理,包括:
- 《Transformer原理及关键模块深入浅出》
- 《GPT系列预训练模型原理讲解》、
- 《大模型时代下Bert去哪啦》、
- 《关于LLaMA 3.1 405B以及小模型的崛起》、
- 《LLaMA3结构关键模块分析》、
- 《强化学习RL与大模型智能体》、
- 《Transformer KV Cache原理深入浅出》
- 《生成式模型算法原理深入浅出》
- 《压缩泛化-对大语言模型智能涌现的理解》
可能有的同学接受起来,还是不够直接。刚好最近看到一个叫做《LLM Visualization》的项目【1,2】,通过3D可视化,将GPT模型的每一个流程都展现得非常直观,在这里做一下分享。
2. 可视化GPT
2.1 全局概览
左侧是LLM的结构,包括Embedding、Layer Norm、Self Attention、Projection、MLP、Transformer、Softmax、Output等章节。右侧则是对应每一个章节的3D可视化视图。
2.2 关于模型的可视化展示
为了展示的方便,我们以nano-gpt【3,4】来表述,该模型仅有85000左右参数,是来自Karpathy大神的项目。nano-gpt是最简单、最快速的中型GPT训练/微调仓库。它是minGPT的重写版本,更注重实用性而非教学。目前仍在积极开发中,但目前的train.py文件可以在OpenWebText上复现GPT-2(124M)的训练,使用单个8X A100 40GB节点大约需要4天的训练时间。代码本身简洁且易读:train.py是一个大约300行的基础训练循环,model.py是一个大约300行的GPT模型定义,支持从OpenAI加载GPT-2的权重。
2.2.1 Embedding
我们之前已经了解了如何使用一个简单的查找表将标记映射到整数序列。这些整数,即标记索引,是我们在模型中唯一一次看到整数的地方。从这里开始,我们将使用浮点数(小数)。示例中使用第4个标记(索引为3)生成输入嵌入的第4列向量。使用标记索引(在这个例子中,B = 1)选择左侧标记嵌入矩阵的第2列。注意,这里使用的是从0开始的索引,因此第一列的索引为0。这会生成一个大小为C = 48的列向量,称之为标记嵌入。由于示例使用第4个位置的标记B(t = 3),因此将取位置嵌入矩阵的第4列。这同样会生成一个大小为C = 48的列向量,我们称之为位置嵌入。请注意,这些位置嵌入和标记嵌入都是在训练过程中学习到的(用蓝色表示)。现在有了这两个列向量,只需将它们相加,就可以得到另一个大小为C = 48的列向量。现在对输入序列中的所有标记运行相同的过程,创建一组同时包含标记值和它们位置的向量。可以看到,对输入序列中的所有标记运行这个过程会生成一个大小为T x C的矩阵。T代表时间,即你可以将序列中较后的标记视为时间上较晚的标记。C代表通道,也被称为“特征”或“维度”或“嵌入大小”。这个长度C是模型的几个“超参数”之一,由设计者根据模型大小和性能之间的权衡来选择。这个矩阵被称之为输入嵌入,现在准备好传递到模型中。
2.2.2 Layer Norm
上一小节中的输入嵌入矩阵是我们第一个Transformer块的输入。Transformer块的第一步是对这个矩阵应用层归一化。层归一化是一个对矩阵中每一列的值分别进行归一化的操作。归一化是深度神经网络训练中的重要步骤,它有助于提高模型在训练过程中的稳定性。可以分别考虑每一列,示例中专注于第4列(t = 3)。归一化的目标是使该列中的平均值等于0,标准差等于1。为此,我们先找到列的平均值(μ)和标准差(σ),然后减去平均值并除以标准差。这里使用的符号是E[x]表示平均值,Var[x]表示方差(长度为C的列的方差)。ε(ε = 1×10⁻⁵)项是为了防止除以零。在聚合层中计算并存储这些值,将它们应用于该列中的所有值。最后,当得到归一化后的值时,将列中的每个元素乘以一个学习到的权重(γ),然后加上一个偏置(β)值,从而得到归一化后的最终值。对输入嵌入矩阵的每一列执行此归一化操作,结果是归一化后的输入嵌入,它将会被传递到自注意力层中。
2.2.3 Self-Attention
自注意力层可以说是Transformer和GPT的核心阶段。在这一阶段,输入嵌入矩阵中的列“互相交互”。直到现在,以及在所有其他阶段中,这些列都可以被独立地看待。
自注意力层由多个头组成,现在先关注其中一个头。
第一步是为归一化后的输入嵌入矩阵中的每一列生成三个向量:Q、K 和 V 向量:
- Q:查询向量(Query vector)
- K:键向量(Key vector)
- V:值向量(Value vector)
为了生成这些向量之一,进行矩阵-向量乘法,并加上一个偏置。每个输出单元都是输入向量的某种线性组合。举例来说,Q向量的生成是通过Q权重矩阵的一行与输入矩阵的一列的点积来完成的。点积运算是将第一个向量的每个元素与第二个向量中对应的元素配对,进行乘法,然后将结果相加。这是一种通用且简单的方法,确保每个输出元素都可以受到输入向量中所有元素的影响(这种影响由权重决定)。因此,点积运算在神经网络中频繁出现。
对Q、K、V向量中的每个输出单元重复此操作:
接下来对于Q(查询)、K(键)和V(值)向量,“键”和“值”让人联想到软件中的字典,其中键映射到值。然后“查询”是用来查找值的东西。
Lookup table:
table = { "key0": "value0", "key1": "value1", ... }
Query Process:
table["key1"] => "value1"
在自注意力的情况下,不是返回一个单独的条目,而是返回一些加权组合的条目。为了找到这种加权,对Q向量与每个K向量进行点积。对这些加权结果进行归一化,然后使用它们与对应的V向量相乘,最后将它们相加。
举个更具体的例子,来看第6列(t = 5),将从中进行查询:
查找表的{K, V}条目是过去的6列,而Q值是当前时间。首先计算当前列(t = 5)的Q向量与之前那些列的每个K向量之间的点积。这些结果存储在注意力矩阵的对应行(t = 5)中。这些点积是衡量两个向量相似性的一种方式。如果它们非常相似,点积会很大;如果它们非常不同,点积会很小或为负。仅使用查询对过去的键进行比较的想法使得这种自注意力成为因果自注意力。这意味着token不能“看到未来”。另一个要素是,在进行点积后,需要除以sqrt(A),其中A是Q/K/V向量的长度。进行这种缩放是为了防止在下一步的归一化(softmax)中大值占主导地位。每行都会被归一化,使其和为1。
最后,可以生成第6列(t = 5)的输出向量。查看归一化后的自注意力矩阵的(t = 5)行,并将每个元素与其他列的V向量进行逐元素乘法。然后将这些结果相加,生成输出向量。因此,输出向量将主要受得分较高的列的V向量的影响。
接下来可以对所有列进行这个操作了。这就是自注意力层一个头的工作过程。因此,自注意力的主要目标是每个列希望从其他列中找到相关信息并提取它们的值,这通过将其查询向量与其他列的键进行比较来实现。并且有一个额外的限制,它只能查看过去的列。
2.2.4 Projection
在自注意力过程结束后,得到了每个头的输出。这些输出是适当混合后的V向量,受Q和K向量的影响。为了组合每个头的输出向量,简单地将它们堆叠在一起。因此,对于时间点t = 4,从3个长度为A = 16的向量变为1个长度为C = 48的向量。值得注意的是,在GPT中,每个头内向量的长度(A = 16)等于C / num_heads。这确保了当它们堆叠在一起时,可以恢复到原始长度C。
接下来,进行投影以获得该层的输出。这是在每一列上进行的简单矩阵-向量乘法,并加上一个偏置。现在有了自注意力层的输出。但并不是直接将这个输出传递到下一个阶段,而是将它与输入嵌入逐元素相加。这个过程,用绿色的垂直箭头表示,称为残差连接或残差路径。和层归一化一样,残差路径对于在深度神经网络中实现有效学习也非常重要。现在已经得到了自注意力的结果,可以将其传递到Transformer的下一个部分:前馈网络。
2.2.5 MLP(FFN)
在自注意力过程之后,Transformer块的另一半是MLP(多层感知器)。在这里它只是一个包含两层的简单神经网络。与自注意力一样,在向量进入MLP之前,先进行层归一化。
在MLP中,将每个长度为C = 48的列向量(独立地)经过以下步骤:
- 进行线性变换,并加上偏置,使向量扩展到长度为4 * C。
- 对每个元素应用GELU激活函数(逐元素操作)。
- 进行线性变换,并加上偏置,将向量恢复到长度C。
跟踪其中一个向量的处理过程:
首先,进行矩阵-向量乘法,并加上偏置,将向量扩展到长度为4 * C。
接下来,对向量的每个元素应用GELU激活函数。这是任何神经网络的关键部分,在这里引入了一些非线性特性。使用的具体函数GELU看起来与ReLU函数(计算为max(0, x))非常相似,但它有一个平滑的曲线,而不是一个尖锐的拐角。
然后,通过另一个矩阵-向量乘法,并加上偏置,将向量投影回长度C。与自注意力加投影部分类似,将MLP的结果与它的输入逐元素相加。现在可以对输入的所有列重复这个过程。MLP部分就此完成。现在得到了Transformer块的输出,可以传递到下一个块。
2.2.6 Output(包含Softmax)
Softmax操作在自注意力机制中被使用,如前一部分所述,并且它也会在模型的最后阶段出现。其目标是将一个向量的值归一化,使它们的总和为1.0。然而,这并不像简单地除以总和那么简单。相反,每个输入值首先要进行指数运算。
这种操作的效果是使所有值都变为正值。一旦得到一个由这些指数化值组成的向量,就可以将每个值除以所有值的总和。这将确保这些值的总和为1.0。由于所有的指数化值都是正数,结果值将介于0.0和1.0之间,这为原始值提供了一个概率分布。
Softmax操作的原理就是这样:只需对值进行指数运算,然后除以总和。然而,softmax操作有一个小小的复杂性。如果输入值中的某些值非常大,那么指数化后的值将会非常大。可能会把一个大数除以一个非常大的数,这可能会导致浮点运算的问题。Softmax操作的一个有用特性是,如果对所有输入值加上一个常数,结果将保持不变。因此,可以找到输入向量中的最大值,并从所有值中减去它。这确保了最大值为0.0,并且softmax操作在数值上保持稳定。
最后,来到了模型的末尾。最终的Transformer块的输出经过层归一化处理,然后使用线性变换(矩阵乘法),这次没有加上偏置。这一最终变换将每个列向量从长度C转换为nvocab的长度。因此,它实际上为每一列的词汇表中的每个单词生成了一个分数,这些分数有一个特殊的名称:logits。“logits”这个名称来源于“对数几率”,即每个标记几率的对数。使用“对数”这个名称是因为接下来应用的softmax会通过指数运算将其转换为几率或概率。为了将这些分数转换为合适的概率,通过softmax操作处理它们。现在,对于每一列,有了模型为词汇表中的每个单词分配的概率。
当通过时间步进模型时,使用最后一列的概率来确定下一个要添加到序列中的token。例如,如果向模型提供了六个token,将使用第六列的输出概率。这一列的输出是一系列概率,实际上需要从中选择一个作为序列中的下一个token。通过“从分布中采样”来实现这一点。也就是说,根据其概率随机选择一个token。例如,一个概率为0.9的标记将会在90%的情况下被选中。当然也可以设置始终选择概率最高的token。还可以通过使用温度参数来控制分布的“平滑度”。较高的温度会使分布更加均匀,而较低的温度会使分布更集中于概率最高的token。通过在应用softmax之前将logits(线性变换的输出)除以温度来实现这一点。由于softmax中的指数运算对较大的数值有很大的影响,使它们彼此更加接近将会减弱这种影响。
总结:上述过程我们通过3D可视化,基本完成对GPT的学习过程的讲解。这里还是强烈建议大家去实际看下3D运行的过程,可以更直观地理解算法原理。
3. 参考材料
【1】LLM Visualization
【2】Understanding GPT: The Inference Perspective
【3】The simplest, fastest repository for training/finetuning medium-sized GPTs
【4】Neural Networks: Zero to Hero