动手构建大模型:阅读笔记
2025-08-02
0.背景
本文是阅读 《Build a Large Language Model 》 的笔记,记录下自己的理解。
过去一段时间关注 AI ,陆续把读研时的习惯捡回来,记录下学习过程。
先多遍阅读、每次覆盖不同要点,并结合手动操作,完全就是读研时学习前沿论文的方式,珍惜这种专注。
现在想起来,自己应该是享受类似的专注感觉:
- 广泛收集一批资料,不奢求一次全都读;
- 穿插着读一些、不奢求逐字逐句读懂、分散时间再读另一些;
- 有的放了 2 年才开始读,也不晚,资料的思路都是相似的、有未过时的部分;
- 再搭配零散看些前沿咨询;
- 突然有一天,融汇贯通,脑袋里的知识开始迅速关联起来,再阅读相关资料、非常流畅。
关联术语:
- gradient 梯度
1. LLM 主体流程
几个主要步骤:
- 1.数据准备:原始的无标签数据
- 2.模型训练:依赖自监督学习,得到
预训练模型
(Pretrained Model),具备 文本续写(Text completion)、小样本学习(Few-shot learning)能力。 - 3.模型微调:使用有标签数据,监督微调后(Supervised fine-tuning),得到
生产可用的模型
。 - 4.模型推理:使用模型,进行推理,得到结果。
- 5.模型评估:使用评估指标,评估模型性能。
其中,涉及 2 类数据:
- 无标签数据:原始数据,如书籍、网页、对话等,用于自监督学习、预训练模型;
- 有标签数据:人工标注的数据,如问题、答案,用于监督微调、生产可用的模型。
GPT 这样的解码器模型是通过逐字预测生成文本,因此它们被视为一种自回归模型。
- 自回归模型会将之前的输出作为未来预测的输入。
- 因此,在 GPT 中,每个新词的选择都是基于之前的文本序列,这样可以提高生成文本的连贯性。
[!NOTE]
自回归,是一种用于
时间序列
分析的统计技术,它假设时间序列的当前值
是其过去值
的函数。自回归模型,使用类似的数学技术来确定序列中,元素之间的概率相关性。然后,它们使用所得知识,来猜测未知序列中的下一个元素。
自相关,用于衡量序列中元素之间的相关性;一般会圈定一个时间窗口,计算窗口内元素之间的相关性。大部分场景下,窗口之前的元素,对窗口之后的元素影响较小。
2. 数据准备
数据准备,前期处理:
- 1.数据清洗:去除重复、缺失、异常数据;
- 2.数据增强:增加数据量,提高模型泛化能力;
- 3.数据标注:人工标注数据,提高模型准确性;
Tips: 尽可能保持文本原样,仅对明确的错误进行调整。
例如: 无需将所有文本转换为小写字母,因为大写字母有助于 LLM 区分专有名词和普通名词,理解句子结构,并学习生成正确的大写文本。
数据送入模型训练之前,需要进行 分词
和 编码
,得到 token
序列。
- 字节对编码(Byte Pair Encoding,
BPE
):一种更为高级的文本分词技术。通过反复合并频繁出现的字符
和子词来构建词汇表。例如,BPE 首先将所有单个字符(“a”,“b”,等)添加到词汇表中。在下一阶段,它将经常一起出现的字符组合合并为子词。例如,“d”和“e”可能会合并成子词“de”,这个组合在许多英语单词中很常见,如“define”、“depend”、“made”和“hidden”。这些合并是通过频率截止值来确定的。
[!TIP]
个人思考: 字节对编码是一种基于统计的方法,它会先从整个语料库中找出最常见的字节对(byte pair),然后把这些字节对合并成一个新的单元。让我们用一个具体的示例来描述这个过程:
假如有句子:“The cat drank the milk because it was hungry”
初始化:BPE会先将句子中每个字符视为一个单独的token
['T', 'h', 'e', ' ', 'c', 'a', 't', ' ', 'd', 'r', 'a', 'n', 'k', ' ', 't', 'h', 'e', ' ', 'm', 'i', 'l', 'k', ' ', 'b', 'e', 'c', 'a', 'u', 's', 'e', ' ', 'i', 't', ' ', 'w', 'a', 's', ' ', 'h', 'u', 'n', 'g', 'r', 'y']
统计最常见的字节对
BPE算法会在这些token中找到出现频率最高的“字节对”(即相邻的两个字符),然后将其合并为一个新的token。
例如这里最常见的字节对时(’t’, ‘h’),因为它在单词”the”和”that”中出现频率较高。
合并字节对
根据统计结果,我们将最常见的字节对(’t’, ‘h’)合并为一个新的token,其它类似
['Th', 'e', ' ', 'c', 'a', 't', ' ', 'dr', 'a', 'nk', ' ', 'th', 'e', ' ', 'm', 'i', 'l', 'k', ' ', 'be', 'c', 'a', 'u', 'se', ' ', 'it', ' ', 'wa', 's', ' ', 'hu', 'n', 'gr', 'y']
重复步骤2和3,得到最终的token序列
['The', ' ', 'cat', ' ', 'drank', ' ', 'the', ' ', 'milk', ' ', 'because', ' ', 'it', ' ', 'was', ' ', 'hungry']
嵌入之前,数据处理步骤:
- 分词:将文本拆分成单词或子词单元, 词元 又称
token
; - 编码:将分词后的
token
转换为数字 ID,tokenID
; - 嵌入:将
tokenID
转换为 嵌入向量(稠密向量
),用于后续的模型计算。- 嵌入层,本质上是一个查找功能,用 token ID 作为
行索引
,从嵌入层的权重矩阵中检索对应行
数据。 - 除了
嵌入向量
,还需要包括位置编码
,用于表示文本的顺序信息。
- 嵌入层,本质上是一个查找功能,用 token ID 作为
分词时,需要考虑 OOV
问题,即 Out of Vocabulary
,即模型未见过的词汇。常见解决办法:
- 使用
unk
表示未知词汇,并且为其分配独立的tokenID
; - 增加
endoftext
, 标识「不同类型」文档的分隔符; - 增加
bos
, 标识文档的开始符; - 增加
eos
, 标识文档的结束符; - 增加
pad
, 标识文档的填充符,批量可能包含不同长度的文本。为了确保所有文本长度一致,较短的文本会用pad
token进行扩展或填充,直到达到批量中最长文本的长度。
对于GPT类大语言模型(LLM)来说,连续向量表示(Embedding)非常重要,原因在于这些模型使用深度神经网络结构,并通过反向传播
算法(back propagation)进行训练。
[!TIP]
个人思考: 上面一段描述说的有些笼统,为什么通过反向传播算法训练的大语言模型必须具有Embedding,让我们通过以下几个方面来分析和思考:
深度神经网络和连续向量表示
GPT 类模型(以及其他深度神经网络)是基于大量的矩阵运算和数值计算构建的,尤其是神经元之间的连接权重和偏置在训练过程中不断更新。这些运算要求输入的数据是数值形式的向量,因为神经网络只能对数值数据进行有效计算,而无法直接处理原始的离散文字数据(如单词、句子)。
- 向量表示:通过将每个单词、句子或段落转换为连续向量(Embedding),可以在高维空间中表示文本的语义关系。例如,通过词嵌入(如 Word2Vec、GloVe)或上下文嵌入(如 GPT 中的词嵌入层),每个单词都被转换为一个向量,这个向量可以用于神经网络的计算。
向量嵌入的作用
连续向量表示不仅让文本数据可以进入神经网络,还帮助模型捕捉和表示文本之间的语义关系。例如:
- 同义词或相似词:在向量空间中,相似的单词可以有接近的向量表示。这种语义相似性帮助模型理解上下文,并在生成文本时提供参考。
- 上下文关系:GPT 等 LLM 模型不仅依赖单词级别的向量表示,还会考虑句子或段落上下文,形成动态嵌入,从而生成更具连贯性的文本。
反向传播算法的要求
深度神经网络通过反向传播算法进行训练,反向传播的本质是利用
梯度下降
法来更新网络的权重,以最小化损失函数
(loss function)。反向传播要求每一层的输入、输出和权重都能够参与梯度计算,而梯度计算只能应用于数值数据。
- 自动微分与梯度计算:在反向传播中,神经网络会根据损失函数的导数来计算梯度,这个过程依赖于自动微分(automatic differentiation)。为了计算每层的梯度,输入的数据必须是数值形式(即向量),否则无法对离散的文本数据求导。
- 梯度更新权重:每次更新网络权重时,神经网络会根据每一层的输入和输出来调整权重,以更好地学习数据的模式。如果输入不是数值形式,就无法实现梯度更新,从而无法通过反向传播训练网络。
3. 关键原理
3.1. 注意力机制
编码器
-解码器
架构的 RNN
循环神经网络的工作原理,关键思想在于:
1.编码器,将整个输入文本处理为一个隐藏状态(记忆单元)。 2.解码器,使用该隐藏状态生成输出,隐藏状态视为一个嵌入向量,它捕获了输入文本的语义信息。
RNN 的限制在于:在解码阶段 RNN 无法直接访问 编码器的早期隐藏状态,即,依赖当前隐藏状态来封装所有相关信息。这种设计可能导致上下文信息的丢失,特别是在依赖关系较长的复杂句子中,这一问题尤为突出。这是典型的 长序列建模
问题,也引发人们设计出 注意力机制。
[!TIP]
个人思考: 了解从RNN到注意力机制的技术变迁对于核心内容的理解至关重要。让我们通过一个具体的示例来理解这种技术变迁:
RNN的局限性
假设我们有一个长句子:“The cat, who was sitting on the windowsill, jumped down because it saw a bird flying outside the window.”
假设任务是预测句子最后的内容,即要理解“it”指的是“the cat”而不是“the windowsill”或其他内容。对于 RNN 来说,这个任务是有难度的,原因如下:
- 长距离依赖问题:在 RNN 中,每个新输入的词会被依次传递到下一个时间步。随着句子长度增加,模型的隐状态会不断被更新,但早期信息(如“the cat”)会在层层传播中逐渐消失。因此,模型可能无法在“it”出现时有效地记住“the cat”是“it”的指代对象。
- 梯度消失问题:RNN 在反向传播中的梯度会随着时间步的增加逐渐减小,这种“梯度消失”使得模型很难在长句中保持信息的准确传播,从而难以捕捉到长距离的语义关联。
注意力机制的解决方法
为了弥补 RNN 的这些不足,注意力机制被引入。它的关键思想是在处理每个词时,不仅依赖于最后的隐藏状态,而是允许模型直接关注序列中的所有词。这样,即使是较远的词也能在模型计算当前词的语义时直接参与。
在上例中,注意力机制如何帮助模型理解“it”指代“the cat”呢?
- 注意力机制的工作原理:当模型处理“it”时,注意力机制会将“it”与整个句子中的其他词进行相似度计算,判断“it”应该关注哪些词。
- 由于“the cat”与“it”在语义上更相关,注意力机制会为“the cat”分配较高的权重,而其他词(如“windowsill”或“down”)则获得较低的权重。
- 信息的直接引用:通过注意力机制,模型可以跳过中间步骤,直接将“it”与“the cat”关联,而不需要依赖所有的中间隐藏状态。
示例中的注意力矩阵
假设使用一个简单的注意力矩阵,模型在处理“it”时,给每个词的权重可能如下(至于如何计算这些权重值后文会详细介绍):
词 The cat who was sitting … it saw bird flying … window 权重 0.1 0.3 0.05 0.05 0.05 … 0.4 0.05 0.02 0.01 … 0.02 在这个注意力矩阵中,可以看到“it”对“the cat”有较高的关注权重(0.3),而对其他词的关注权重较低。这种直接的关注能力让模型能够高效捕捉长距离依赖关系,理解“it”与“the cat”的语义关联。
自注意力机制,是一种允许输入序列中的每个位置
都能与同一序列中所有位置
进行交互,并权衡他们的重要性。
在自注意力机制中,我们的目标是:
- 为输入序列中的每个元素
x(i)
计算其对应的上下文向量z(i)
。 上下文向量
可以被解释为一种增强的嵌入向量。
[!TIP]
个人思考: 这里对于注意力得分的计算描述的比较笼统,仅仅说明了将当前的输入Token向量与其它Token的向量进行点积运算计算注意力得分,实际上,每个输入Token会先通过权重矩阵W分别计算出它的Q、K、V三个向量,这三个向量的定义如下:
- Q向量(查询向量):查询向量代表了这个词在寻找相关信息时提出的问题
- K向量(键向量):键向量代表了一个单词的特征,或者说是这个单词如何”展示”自己,以便其它单词可以与它进行匹配
- V向量(值向量):值向量携带的是这个单词的具体信息,也就是当一个单词被”注意到”时,它提供给关注者的内容
更通俗的理解: 想象我们在图书馆寻找一本书(
Q向量
),我们知道要找的主题(Q向量
),于是查询目录(K向量
),目录告诉我哪本书涉及这个主题,最终我找到这本书并阅读内容(V向量
),获取了我需要的信息。具体生成Q、K、V向量的方式主要通过线性变换:
Q1 = W_Q * (E1 + Pos1) K1 = W_K * (E1 + Pos1) V1 = W_V * (E1 + Pos1)
依次类推,为所有token生成
Q
,K
,V
向量,其中W_Q
,W_K
和W_V
是Transformer训练出的权重(每一层不同)针对每一个目标token,Transformer会计算它的
Q向量
与其它所有的token的K向量
的点积,以确定每个词对当前词的重要性(即注意力分数)假如有句子:“The cat drank the milk because it was hungry”
例如对于词
cat
的Q向量 Q_cat
,模型会计算:
score_cat_the = Q_cat · K_the
— 与the
的语义相关度score_cat_drank = Q_cat · K_drank
— 与drank
的语义相关度score_cat_it = Q_cat · K_it
— 与it
的语义相关度- 依此类推,得到
cat
与句子中其它所有token的注意力分数[score_cat_the、score_cat_drank、socre_cat_it、……]
计算上下文向量
前,会对权重归一化,将注意力得分
、转换为注意力权重
:一般用 softmax 函数。
- 归一化的主要目的,是使注意力权重之和为 1。
- 这种归一化,是一种有助于解释和保持LLM训练稳定性的惯例
理解点积
点积运算本质上是一种将两个向量按元素相乘后再求和的简单方式,我们可以如下演示:
res = 0. for idx, element in enumerate(inputs[0]): res += inputs[0][idx] * query[idx] print(res) print(torch.dot(inputs[0], query))
输出结果确认,逐元素相乘的和与点积的结果相同。
tensor(0.9544) tensor(0.9544)
除了将点积运算视为结合两个向量并产生标量结果的数学工具之外,点积也是一种相似度的衡量方法,因为它量化了两个向量的对齐程度:较高的点积值表示向量之间有更高的对齐程度或相似度。在自注意力机制的背景下,点积决定了序列中元素之间的关注程度:点积值越高,两个元素之间的相似度和注意力得分就越高。
[!TIP]
个人思考: 这里稍微延伸探讨一下
Softmax
, 它是一种常用的激活函数,尤其在神经网络的分类任务中被广泛使用。它的作用是将一个任意的实数向量转换为一个概率分布,且所有元素的概率之和为 1。下面通过例子来说明 softmax 的原理、好处,以及它在神经网络中的使用原因。
Softmax 的原理
Softmax 函数的公式如下:
\[\text{softmax}\left(z_{i}\right)=\frac{e^{z_{i}}}{\sum_{j} e^{z_{j}}}\]其中zi是输入的每个分数(即未激活的原始值),e 是自然对数的底。这个公式的作用是将输入向量中的每个元素转换为一个概率值,且所有值的和为 1。
Softmax 的好处
- 归一化输出为概率:Softmax 将输出转换为 0 到 1 之间的概率,且所有类别的概率之和为 1,方便解释结果。例如,在分类任务中,输出可以直接表示模型对各类别的信心。
- 平滑和放大效果:Softmax 不仅能归一化,还具有平滑和放大效果。较大的输入值会被放大,较小的输入值会被抑制,从而增强模型对最优类别的区分。
- 支持多分类问题:与 sigmoid 不同,Softmax 适用于多类别分类问题。它可以输出每个类别的概率,使得模型可以处理多分类任务。
神经网络为什么喜欢使用 Softmax
在神经网络中,特别是分类模型(如图像分类、文本分类)中,Softmax 层通常用作最后一层输出。原因包括:
- 便于优化:在分类任务中,Softmax 输出的概率分布可与真实的标签概率进行比较,从而计算交叉熵损失。交叉熵损失的梯度较为稳定,便于模型的优化。
- 概率解释:Softmax 输出可以解释为“模型对每个类别的信心”,使得输出直观可理解。
- 与交叉熵的结合:Softmax 与交叉熵损失函数结合效果特别好,可以直接将模型预测的概率分布与真实标签比较,从而更快收敛,效果更好。
激活函数
激活函数(
Activation Function
)是神经网络中的核心组件,它的作用类似于神经元的“开关”或“过滤器”,负责决定神经元是否被激活(即输出信号),以及激活的程度。在神经网络中,激活函数通常用于将输入信号转换为输出信号,从而实现非线性变换。 常见的激活函数包括:
- Sigmoid:将输入信号转换为0到1之间的概率值,常用于二分类问题。
- ReLU:将输入信号转换为0到正无穷之间的值,常用于多分类问题。
- Softmax:将输入信号转换为0到1之间的概率值,常用于多分类问题。
3.2. 上下文向量
疑问:llm 大模型的自注意力机制中,每个输入 token 对应的上下文向量,有什么作用?被用于做什么?
在 LLM 大模型的自注意力机制 (Self-Attention) 里,每个输入 token(词或子词)在计算时,都会得到一个 上下文向量(context vector)。
1. 上下文向量是怎么来的?
在自注意力里:
- 输入 token 先被映射成 查询 Q、键 K、值 V 三个向量。
- 对于某个 token,它的 Q 会与所有其他 token 的 K 做相似度计算(点积 + softmax),得到一组注意力权重。
- 然后用这些权重,对所有 token 的 V 做加权求和,得到一个新的向量,这个就是 上下文向量。
换句话说:
上下文向量 = 当前 token 从其他 token 那里「聚合」来的信息。
2. 上下文向量的作用
上下文向量不是终点,而是 后续计算的输入,主要用在以下方面:
a. 更新 token 表示
- 上下文向量会和原始 token 的 embedding/隐藏状态结合(加残差 + LayerNorm 等),形成这个 token 的新表示。
- 这样每个 token 的表示,不仅包含了它本身的语义,还带有和其它 token 的关系(依赖、语法、上下文意义)。
b. 为下一层提供输入
- Transformer 是堆叠的,每一层都会产生新的上下文向量。
- 在高层,token 的表示越来越「全局化」,逐渐能理解长距离依赖。
c. 用于预测下一个 token(解码阶段)
- 在语言模型中,最后一层的上下文向量会被传入一个 输出层 (linear + softmax),用来预测下一个 token 的概率分布。
- 换句话说,模型生成一个字时,实际上是在用前文所有 token 的上下文向量来推断。
3. 可以打个比喻
- 每个 token 像一个学生,V 是他自己的知识点。
- 注意力权重像是“这个学生对班里同学知识点的关注度”。
- 上下文向量就像是“这个学生听完大家的发言后,重新整理出的综合理解”。
- 下一层再用这个综合理解,继续交流、深化,最后全班同学都能对问题有比较一致的认识(即捕捉到全局上下文)。
4.总结一句话
在 LLM 的自注意力里,每个输入 token 的上下文向量,起到“把自己和全局上下文融合”的作用,既用于更新 token 的表示,也直接服务于最后的下一个 token 预测。
3.3. 缩放:注意力得分
缩放注意力得分:通过将注意力得分除以keys
嵌入维度的平方根来进行缩放(注意,取平方根在数学上等同于指数为 0.5 的运算)。再使用 softmax 函数来计算注意力权重。
[!NOTE]
缩放点积注意力机制的原理
对嵌入维度大小进行归一化的原因是为了避免出现小梯度,从而提高训练性能。例如,当嵌入维度增大时(在 GPT 类大型语言模型中通常超过一千),较大的点积在反向传播中应用 softmax 函数后,可能会导致非常小的梯度。随着点积的增大,softmax 函数的行为会更加类似于阶跃函数,导致梯度接近于零。这些小梯度可能会显著减慢学习速度,甚至导致训练停滞。
通过嵌入维度的平方根进行缩放,正是自注意力机制被称为‘缩放点积注意力’的原因。
[!TIP]
个人思考: 这里再稍微解释一下上述关于缩放点积注意力的机制。在自注意力机制中,查询向量(Query)与键向量(Key)之间的点积用于计算注意力权重。然而,当嵌入维度(embedding dimension)较大时,点积的结果可能会非常大。那么大的点积对接下来的计算有哪些具体影响呢?
- Softmax函数的特性:在计算注意力权重时,点积结果会通过Softmax函数转换为概率分布。而Softmax函数对输入值的差异非常敏感,当输入值较大时,Softmax的输出会趋近于0或1,表现得类似于阶跃函数(step function)。
- 梯度消失问题:当Softmax的输出接近0或1时,其梯度会非常小,接近于零(可以通过3.3.1小节中提到的Softmax公式推断)。这意味着在反向传播过程中,梯度更新幅度会很小,导致模型学习速度减慢,甚至训练停滞。
为了解决上述问题,在计算点积后,将结果除以嵌入维度的平方根(即 \(\sqrt{dk}\)),其中
dk
是键向量的维度。这样可以将点积结果缩放到适当的范围,避免Softmax函数进入梯度平缓区,从而保持梯度的有效性,促进模型的正常训练。
3.4. 权重矩阵 Q\K\V
疑问:llm 的注意力机制中,为什么要使用 Q/K/V 权重矩阵,只使用 Q/V 不行吗?或者,使用更多权重矩阵可以吗?
1. Q/K/V 各自的角色
在 Transformer 自注意力里,输入 token 的 embedding/隐藏向量 $x$ 会经过 三个不同的线性变换:
- Q(Query):代表“我要去找什么信息”,像是提问。
- K(Key):代表“我能提供什么信息”,像是回答的索引标签。
- V(Value):代表“真正携带的信息内容”,像是回答的内容。
然后通过公式:
\[Attention(Q, K, V) = softmax\left(\frac{QK^T}{\sqrt{d_k}}\right)V\]核心逻辑:
- Q 和 K 交互,计算相关性(谁和谁更相关);
- 相关性用来加权 V,得到上下文向量。
2. 为什么不能只用 Q/V?
假设只用 Q 和 V(没有 K):
-
如果 没有 K,Q 和 V 就必须直接交互。那问题是:
- Q 的角色是“查询”,V 是“信息内容”,但缺了“匹配的索引(K)”,查询和内容之间缺乏一个统一的匹配标准。
- 这会让模型很难表达“选择哪些信息”,等于把“找信息”和“传递信息”混在一起,导致表达能力下降。
-
举个比喻:
- K 就像是图书馆里书的标签,Q 是读者的需求,V 是书的实际内容。
- 如果没有 K,读者只能直接拿内容去对比需求,但一本书有几十万字,匹配效率和效果都会差。
所以,K 的存在提供了一个 抽象的匹配空间,让 Q 可以快速决定该关注哪些 V。
3. 能不能用更多的权重矩阵?
可以,但不一定有意义。
-
目前设计(Q/K/V)是最小充分集:
- Q/K 用于计算注意力分布;
- V 用于存储最终要传递的信息。
- 三者分工明确,效果好且计算量合理。
-
如果增加更多矩阵:
- 例如再加一个 M,用于修饰 V(有点像门控机制);
- 或者引入 R,专门建模位置关系(相对位置编码就是类似做法)。
- 理论上可以增强模型表达能力,但代价是增加参数量和计算复杂度。
实际上,学术界确实有一些扩展版本:
- 多头注意力(Multi-Head Attention) → 实际就是复制多组 Q/K/V 矩阵,让不同的头去学习不同的关系。
- 相对位置编码(如 Transformer-XL, RoFormer) → 本质上是对 QK^T 加了额外的 bias 矩阵。
- 线性注意力 / 旋转注意力 → 对 Q/K 的计算方式改造。
4. 总结
- Q/K/V 三者缺一不可:Q 负责提问,K 负责索引,V 负责内容。
- 不能只用 Q/V,否则没有匹配基准,信息选择会混乱。
- 可以扩展更多矩阵,但一般以 Multi-Head 或相对位置偏置等形式实现;核心仍然围绕 Q/K/V 展开。
5. 类比图
类比图(图书馆:读者需求=Q,书标签=K,书内容=V),让你更直观地理解为什么 K 不能省?
3.5. dropout 剪枝
Dropout 在深度学习中是一种技术,即在训练过程中随机忽略一些隐藏层单元,实际上将它们“丢弃”。这种方法有助于防止过拟合,确保模型不会过于依赖任何特定的隐藏层单元组合。需要特别强调的是,Dropout 仅在训练过程中使用,训练结束后则会禁用。
在 Transformer 架构中(包括 GPT 等模型),注意力机制中的 Dropout 通常应用于两个特定区域:计算注意力得分之后,或将注意力权重应用于 value 向量之后。
[!TIP]
个人思考: Dropout相当于丢弃一定比例的注意力权重,这表明对输入中的某些token关注度降为0了(完全不关注),这样的处理方式难道对最终的预测效果没有影响么?另外如何理解Dropout之后的缩放操作是为了保持注意力在不同阶段的平衡?
经过查阅额外的资料及深度思考,我觉得可以从以下几个方面理解上述的疑问:
Dropout 的目的:提高模型的泛化能力
dropout 的设计初衷是提高模型的泛化能力。通过随机丢弃一部分神经元或注意力权重,dropout 迫使模型在每次训练时学习略有不同的表示方式,而不是依赖某一特定的注意力模式。这种随机化的训练方式可以帮助模型在面对新数据时更具鲁棒性,减少过拟合的风险。
注意力机制的冗余性
在 Transformer 的注意力机制中,模型通常会对多个 token 进行注意力计算,实际上会有一些冗余信息。也就是说,不同 token 之间的信息通常会有部分重叠,并且模型能够从多个来源获取类似的信息。在这种情况下,dropout 随机丢弃一部分注意力权重并不会完全破坏模型的性能,因为模型可以依赖于其他未被丢弃的注意力路径来获取所需信息。
缩放操作的作用
在应用 dropout 时,一部分注意力权重被随机置零(假设 dropout 率为 p)。剩余的权重会被放大,其放大倍数为 \(\frac{1}{1-p}\)。放大后的权重记为 z′:
\[z_{i}^{\prime}=\frac{z_{i}}{1-p} \quad \text { (对于未被置零的权重) }\]此时,未被置零的注意力权重 \(\mathbf{z}'\) 将作为 Softmax 的输入。因此,dropout 后的缩放对 Softmax 有两个主要影响:
- 增大未遮盖值的相对差异:放大剩余权重后,它们的数值相对于被置零的权重增大,从而拉大了非零元素之间的相对差异。这使得在 Softmax 计算中(通过前文提过的Softmax公式推导,输入值的差异越大,输出分布就会越尖锐;而输入值差异越小,输出分布就会越平滑),剩下的值之间的对比更明显。
- 影响 Softmax 输出的分布形态:当未被置零的权重值被放大后,它们在 Softmax 输出中会更具代表性,注意力分布会更集中(即更尖锐),让模型更关注特定的 token。
缩放后的 Softmax 输入导致注意力分布更倾向于少数的高权重 token,使得模型在当前步骤更关注这些 token 的信息。这对模型的影响包括:
- 增强模型的选择性关注:在训练中,模型会在每个步骤中随机选择不同的 token 进行更高的关注,这使模型在学习时不会依赖特定 token 的注意力。
- 确保总注意力强度保持一致:即便经过 dropout 丢弃了一部分权重,缩放保证了剩余权重在 Softmax 后的分布与未应用 dropout 时类似。
训练过程中多次迭代弥补信息丢失
在训练过程中,每个 batch 中的 dropout 掩码都是随机生成的。也就是说,在每次训练时被丢弃的注意力权重是随机的,并不会始终忽略相同的 token。这种随机性确保了在训练过程中,模型会在多个迭代中多次关注到每个 token。因此,即便某个 token 在当前的训练步中被忽略,在未来的训练步骤中它仍然会被关注到,从而在整体上避免了信息丢失的问题。
3.6. 层归一化
层归一化,以提高神经网络训练的稳定性和效率。
归一化的核心思想:将神经网络层的激活(输出)调整为均值
为 0,方差
为 1(即单位方差),即 正态分布
- 这种调整可以加速权重的收敛速度,确保训练过程的一致性和稳定性。
- 现代 Transformer 架构中,层归一化通常应用于多头注意力模块的前后以及最终输出层之前。
3.7. 前馈神经网络 FFN
FeedForward 模块是一个小型神经网络,由两个线性层和一个 GELU 激活函数组成。
FeedForward 模块对模型能力的增强(主要体现在从数据中学习模式并泛化方面)起到了关键作用。尽管该模块的输入和输出维度相同,但在内部,它首先通过第一个线性层将嵌入维度扩展到一个更高维度的空间(如上图所示)。之后再接入非线性 GELU 激活,最后再通过第二个线性层变换回原始维度。这样的设计能够探索更丰富的表示空间。
[!TIP]
个人思考: 这段描述一笔带过了扩展和收缩嵌入维度为模型训练带来的好处,那到底该如何理解这样的设计能够探索更丰富的表示空间呢?
可以将扩展和收缩的过程类比为一种数据解压缩与重新压缩的机制:
- 扩展(解压缩):假设我们有一段压缩的音乐文件(例如 MP3),里面包含了音频的基本信息。通过解压缩(扩展),我们把这个文件变成了一个更高质量的音频格式,允许我们看到(听到)更多的细节,比如乐器的细微声响和音调变化。
- 特征提取:接着,我们可以在这个高质量的音频文件中应用各种音频处理算法(相当于非线性激活),分析出更多细节,比如每种乐器的声音特点。
- 收缩(压缩):最后,我们将音频再次压缩为一种更适合传输和存储的格式。虽然最终文件变小了,但这个文件已经包含了之前提取出的更多的声音细节。
将这种理解再应用到神经网络中,扩展后的高维空间可以让模型“看到”输入数据中更多的隐藏特征,提取出更丰富的信息。然后在收缩回低维度时,这些丰富的特征被整合到了输入的原始维度表示中,使模型最终的输出包含更多的上下文和信息。
3.8. 残差连接
残差连接,是一种在神经网络中广泛使用的技巧,用于帮助模型训练更深层的网络:用于缓解梯度消失
问题。梯度消失是指,在训练中指导权重更新的梯度在反向传播过程中逐渐减小,导致早期层(靠近输入端的网络层)难以有效训练。
[!TIP]
个人思考: 看到这里,不知各位读者是否真正理解了快捷连接在深度神经网络中的作用,这里其实涉及到快捷连接的两个重要的作用:
- 保持信息(或者说是特征)流畅传递
- 缓解梯度消失问题
让我们逐一解读,LLM 中的每个Transformer 模块通常包含两个重要组件(可以先阅读完4.5节,再回头看这里的解读):
- 自注意力层(Self-Attention Layer):计算每个 token 与其他 token 的关联,帮助模型理解上下文。
- 前馈网络(Feed Forward Network):对每个 token 的嵌入(embedding)进行进一步的非线性转换,使模型能够提取更复杂的特征。
这两个部分都在层归一化(Layer Normalization)和快捷连接(Shortcut Connections)的配合下工作。
假设我们正在训练一个 LLM ,并希望它理解下面的句子:
The cat sat on the mat because it was tired.
模型需要通过多个 Transformer 层来逐层处理该句子,使得每个词(token)在上下文中能被理解。为了达到这一目的,每个 token 的嵌入会在多层中进行注意力计算和前馈网络处理。
没有快捷连接时的情况
如果没有快捷连接,那么每个 Transformer 层的输出就直接传递到下一个层。这种情况下,网络中的信息流大致如下:
- 层间信息传递的局限:假设当前层的注意力机制计算出了“it”和“cat”之间的关系,如果前馈网络进一步转换了这个信息,那么下一层就只能基于该层的输出,可能丢失一些最初的语义信息。
- 梯度消失:在训练过程中,梯度从输出层逐层向回传播。如果层数过多,梯度会逐渐变小(即“梯度消失”),从而导致模型难以有效更新前面层的参数。
这种情况下,由于信息不能直接流动到更深层次的网络,可能会导致模型难以有效捕捉到前层的一些原始信息。
加入快捷连接后的情况
加入快捷连接后,信息可以在层与层之间直接跳跃。例如,假设在第 n 层,我们有输入 \(X_{n}\) ,经过注意力和前馈网络得到输出 \(F(X_{n})\) 。加入快捷连接后,这一层的输出可以表示为:
\[\text { 输出 }=X_{n}+F\left(X_{n}\right)\]这意味着第 n 层的输出不仅包含了这一层的新信息 \(F(X_{n})\) ,还保留了原始输入 \(X_{n}\) 的信息。下面是这样做的好处:
保留原始信息
快捷连接让输入的原始信息直接传递到后续层,避免了在多层处理过程中丢失重要信息。例如,“it” 和 “cat” 之间的关系在较浅层中被捕捉到后,即使后面的层有进一步的处理,模型依然能够从快捷连接中获得最初的上下文信息。
减轻梯度消失
假设我们有一个简单的三层网络,第三层的输出 O 是整个网络的输出。我们从损失函数 LLL 开始计算梯度:
根据反向传播的原理,无快捷连接时,梯度必须逐层传递,如下:
\[\frac{\partial L}{\partial X_{1}}=\frac{\partial L}{\partial X_{3}} \cdot \frac{\partial X_{3}}{\partial X_{2}} \cdot \frac{\partial X_{2}}{\partial X_{1}}\]这里,如果某一层的梯度值很小,那么梯度会被逐层缩小,导致梯度消失。
有快捷连接时,假设我们在每一层之间都添加快捷连接,梯度的传播路径就多了一条直接路径:
\[\frac{\partial L}{\partial X_{1}}=\frac{\partial L}{\partial\left(X_{1}+F\left(X_{1}\right)\right)} \cdot\left(1+\frac{\partial F\left(X_{1}\right)}{\partial X_{1}}\right)\]这样,即使 \(\frac{\partial F\left(X_{1}\right)}{\partial X_{1}}\) 很小,梯度依然可以通过 111 这条路径直接传递到更前面的层。
4. 核心架构
llm 架构,核心模块:
4.1. 预测下一个 token
疑问:训练 LLM 模型时,预测下一个 token 时,使用的是「上一个 token」计算出来的「注意力权重」反算出来概率最大的 token吗?
LLM 在训练时是如何预测下一个 token 的:
1. 输入是什么?
假设我们有一句话:
“I like to eat”
在训练时,模型看到的输入 token 序列是:
[I] [like] [to] [eat]
模型的任务是 预测下一个 token,也就是:
I -> like
like -> to
to -> eat
eat -> <end>
2. 注意力在做什么?
- 每个位置的 token(比如 “like”)会通过 Q/K/V + 多层 Transformer 得到一个 上下文向量。
- 这个上下文向量融合了前面 token 的信息(因为是 自回归注意力,不能看到未来)。
3. 如何得到下一个 token 的概率?
-
上下文向量会传到最后一个 输出层 (linear + softmax):
\[P(\text{next token} | \text{context}) = \text{Softmax}(W \cdot h_t)\]- \(h_t\):当前位置(比如 “like”)的上下文向量。
- \(W\):输出层权重(大小是 \(vocab\_size × d\_model\))。
- Softmax:把结果变成概率分布。
所以,它不是直接用「注意力权重」来反推,而是: 注意力权重 → 融合得到上下文向量 → 输出层映射到词表 → Softmax 得到概率分布。
4. 训练目标
-
在训练时,模型会算交叉熵损失:
\[\mathcal{L} = - \sum_{t} \log P(y_t | y_{<t})\]比如,模型在看到 “I like to” 时预测的下一个 token 概率分布,和真实的 “eat” 做对比,逼近真实分布。
5. 总结
- 注意力权重 只是中间过程,用来算「上下文向量」。
- 真正预测下一个 token,是靠 上下文向量经过输出层 + softmax 得到的概率分布。
4.2. 交叉熵损失
模型输出的是 上下文向量
,通过 输出层 + softmax
得到概率分布,再通过 交叉熵损失
计算损失,反向传播更新模型权重。
[!NOTE]
反向传播
如何最大化目标 token 的 softmax 概率值?整体思路是通过更新模型权重,使模型在生成目标 token 时输出更高的概率值。权重更新通过一种称为反向传播的过程来实现,这是一种训练深度神经网络的标准技术。
反向传播需要一个损失函数,该函数用于计算模型预测输出与实际目标输出之间的差异(此处指与目标 token ID 对应的概率)。这个损失函数用于衡量模型预测与目标值的偏差程度。
[!TIP]
个人思考: 在继续接下来的计算之前,我们首先来探讨一下,对数在损失函数的应用中到底有什么作用。
为什么要用概率的对数
在 LLM 中,概率得分通常是小于1的数(例如0.1、0.05等),直接用这些数进行计算和优化可能会面临一些问题。比如,如果多个概率相乘,结果会变得非常小,甚至接近0。这种情况称为“数值下溢”(Numerical Underflow),可能导致计算不稳定。
假设我们有三个概率值,分别为0.2、0.1和0.05。如果我们计算这些值的乘积,结果是:
\[0.2×0.1×0.05=0.001\]这个值非常小,尤其在深度学习或概率模型中,我们通常会有成千上万个概率需要相乘,这样会导致最终的乘积接近0甚至为0,造成数值计算的不稳定性。
如果我们对这些概率值取对数,然后相加,而不是直接相乘,我们可以避免这个问题。例如,对这三个值取自然对数(logarithm)后再相加:
\[ln(0.2)+ln(0.1)+ln(0.05)≈−1.6094+(−2.3026)+(−2.9957)=−6.9077\]虽然这个和也是负数,但它不会像直接相乘的结果那样接近于0,避免了数值下溢的问题。对数的累加性质允许我们将原本的累乘操作转换为累加,使得计算更加稳定和高效。
对数概率在损失函数中的作用
GPT模型训练的目标是最大化正确目标 token 的概率,通常,我们会使用交叉熵损失来衡量模型预测与实际目标之间的差异。对于一个目标 token 序列 y=(y1,y2,…,yn),GPT会生成一个对应的预测概率分布 P(y∣x),其中 x 是模型的输入。
交叉熵损失的公式:
在计算交叉熵损失时,我们希望最大化模型分配给每个正确目标token的概率。交叉熵损失的数学公式为:
\[\text { Loss }=-\sum_{t=1}^{T} \ln P\left(y_{t} \mid x, \theta\right)\]其中:
- T 是序列长度
- \(y_{t}\) 是在位置 t 上的目标token
- \(P(y_{t}∣x,θ)\) 是模型在参数 θ 下对目标token \(y_{t}\) 的条件概率
在公式中,对每个token的概率 \(P(y_{t}∣x,θ)\) 取对数,将乘积形式的联合概率转换为求和形式,有助于避免数值下溢,同时简化优化过程。
[!NOTE]
交叉熵损失
本质上,交叉熵损失是在机器学习和深度学习中一种常用的度量方法,用于衡量两个概率分布之间的差异——通常是标签的真实分布(此处为数据集中的 token)和模型的预测分布(例如,LLM 生成的 token 概率)。
在机器学习,特别是 PyTorch 等框架中,cross_entropy 函数用于计算离散输出的损失,与模型生成的 token 概率下的目标 token 的负平均对数概率类似。因此,cross entropy 和负平均对数概率这两个术语在计算上有关联,实践中经常互换使用。
5.实例代码
模型核心:生成 上下文向量
,如下。
# Listing 4.7 The GPT model architecture implementation
class GPTModel(nn.Module):
def __init__(self, cfg):
super().__init__()
self.tok_emb = nn.Embedding(cfg["vocab_size"], cfg["emb_dim"])
self.pos_emb = nn.Embedding(cfg["context_length"], cfg["emb_dim"])
self.drop_emb = nn.Dropout(cfg["drop_rate"])
self.trf_blocks = nn.Sequential(
*[TransformerBlock(cfg) for _ in range(cfg["n_layers"])])
self.final_norm = LayerNorm(cfg["emb_dim"])
self.out_head = nn.Linear(
cfg["emb_dim"], cfg["vocab_size"], bias=False
)
def forward(self, in_idx):
batch_size, seq_len = in_idx.shape
tok_embeds = self.tok_emb(in_idx)
pos_embeds = self.pos_emb(torch.arange(seq_len, device=in_idx.device)) #A
x = tok_embeds + pos_embeds
x = self.drop_emb(x)
x = self.trf_blocks(x)
x = self.final_norm(x)
logits = self.out_head(x)
return logits
#A 设备设置将根据输入数据所在的位置选择在 CPU 或 GPU 上训练模型
基于核心 model,实现 token 生成过程:
# Listing 4.8 A function for the GPT model to generate text
def generate_text_simple(model, idx, max_new_tokens, context_size): #A
for _ in range(max_new_tokens):
idx_cond = idx[:, -context_size:] #B
with torch.no_grad():
logits = model(idx_cond)
logits = logits[:, -1, :] #C
probas = torch.softmax(logits, dim=-1) #D
idx_next = torch.argmax(probas, dim=-1, keepdim=True) #E
idx = torch.cat((idx, idx_next), dim=1) #F
return idx
#A idx 是当前上下文中索引的数组,形状为 (batch, n_tokens)
#B 若上下文长度超出支持范围,则进行裁剪。例如,若模型仅支持 5 个 token,而上下文长度为 10,仅使用最后 5 个 token 作为上下文
#C 仅关注最后一个时间步,将形状从 (batch, n_token, vocab_size) 转换为 (batch, vocab_size)
#D probas 的形状为 (batch, vocab_size)
#E idx_next 的形状为 (batch, 1)
#F 将采样的索引追加到当前序列中,此时 idx 的形状为 (batch, n_tokens+1)
工具函数,用于计算由训练和验证加载器返回的批量数据的交叉熵损失:
def calc_loss_batch(input_batch, target_batch, model, device):
input_batch, target_batch = input_batch.to(device), target_batch.to(device) #A
logits = model(input_batch)
loss = torch.nn.functional.cross_entropy(
logits.flatten(0, 1), target_batch.flatten()
)
return loss
#A 将数据传输到指定设备(如 GPU),使数据能够在 GPU 上处理。
训练流程:
# Listing 5.3 The main function for pretraining LLMs
def train_model_simple(model, train_loader, val_loader, optimizer, device, num_epochs,
eval_freq, eval_iter, start_context, tokenizer):
train_losses, val_losses, track_tokens_seen = [], [], [] #A
tokens_seen, global_step = 0, -1
for epoch in range(num_epochs): #B
model.train()
for input_batch, target_batch in train_loader:
optimizer.zero_grad() #C
loss = calc_loss_batch(input_batch, target_batch, model, device)
loss.backward() #D
optimizer.step() #E
tokens_seen += input_batch.numel()
global_step += 1
if global_step % eval_freq == 0: #F
train_loss, val_loss = evaluate_model(
model, train_loader, val_loader, device, eval_iter)
train_losses.append(train_loss)
val_losses.append(val_loss)
track_tokens_seen.append(tokens_seen)
print(f"Ep {epoch+1} (Step {global_step:06d}): "
f"Train loss {train_loss:.3f}, Val loss {val_loss:.3f}")
generate_and_print_sample( #G
model, tokenizer, device, start_context
)
return train_losses, val_losses, track_tokens_seen
#A 初始化用于记录损失和已处理 token 数量的列表
#B 开始主训练循环
#C 重置上一批次的损失梯度
#D 计算损失梯度
#E 使用损失梯度更新模型权重
#F 可选的评估步骤
#G 每个 epoch 结束后打印示例文本
原文地址:https://ningg.top/ai-series-build-llm-from-scratch/