动手构建大模型:阅读笔记
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]
BPE
(Byte Pair Encoding)是 一种子词分词算法,在 NLP 领域广泛用于词表构建和文本 > tokenization(GPT、BERT 等模型中用到的就是类似方法)。它的核心思想是:通过统计频繁出现
的字符对
(byte pair),逐步合并成新的子词
单元,从而得到一个紧凑又高效的词表。下面详细描述 BPE 的过程:
一、BPE 的步骤
- 准备训练语料
- 输入是一大段训练文本。
- 最初,把所有单词拆成 字符序列(通常还会在单
词末尾
加特殊分隔符,比如_
或</w>
来表示单词结束)。例如:
"low", "lower", "newest", "widest"
初始拆分为:
l o w </w> l o w e r </w> n e w e s t </w> w i d e s t </w>
统计字符对频率
- 遍历所有词序列,统计
相邻
字符对(pair) 的频率。- 例如上面数据中,最常见的 pair 可能是
('l', 'o')
或('e', 's')
。合并出现频率最高的符号对
- 找出
频率最高
的符号对,把它们合并
成一个新的单元
(子词)。- 例如合并
('l', 'o')
→lo
,之后所有出现"l o"
的地方都替换成"lo"
。重复迭代
- 不断重复步骤 2 和步骤 3,每次合并一个最频繁的 pair。
- 每次合并后,词表中会增加一个新的子词单元(token)。
例如迭代过程可能是:
l o w </w> → lo w </w> lo w </w> → low </w>
最后可能得到
low
、er
、est
、wid
、new
这样的子词。- 停止条件
- 当达到预设的 词表大小(vocabulary size) 或
迭代次数
时,停止合并。- 最终得到的就是一个既能覆盖训练语料,又能较好泛化到新词的 子词词表。
二、BPE 的特点
- 优点:
- 可以处理
溢出词表词
(OOV
),比如,新词可以拆解为已知子词组合。- 词表
规模可控
,比逐字符
分割更高效
,比逐词
分割泛化
更好。缺点:
- 合并规则依赖于语料,可能产生一些
不直观的子词切分
(比如ing
可能被拆成in g
)。- 对
语言学结构
没有显式建模,只是基于频率
的统计方法。
嵌入之前,数据处理步骤:
- 分词:将文本拆分成单词或子词单元, 词元 又称
token
; - 编码:将分词后的
token
转换为数字 ID,tokenID
; - 嵌入:将
tokenID
转换为 嵌入向量(稠密向量
),用于后续的模型计算。- 嵌入层,本质上是一个
查找
功能,用 token ID 作为行索引
,从嵌入层的权重矩阵中检索对应行
数据。 - 除了
嵌入向量
,还需要包括位置编码
,用于表示文本的顺序信息
。
- 嵌入层,本质上是一个
分词时,需要考虑 OOV
问题,即 Out of Vocabulary
,即模型未见过的词汇。常见解决办法:
- 使用
unk
表示未知词汇,并且为其分配独立的tokenID
; - 增加
endoftext
, 标识「不同类型」文档的分隔符; - 增加
bos
, 标识文档的开始符; - 增加
eos
, 标识文档的结束符; - 增加
pad
, 标识文档的填充符,批量可能包含不同长度的文本。为了确保所有文本长度一致,较短的文本会用pad
token进行扩展或填充,直到达到批量中最长文本的长度。
对于GPT类大语言模型(LLM)来说,连续向量表示(Embedding)非常重要,原因在于这些模型使用深度神经网络结构,并通过反向传播
算法(back propagation)进行训练。
[!TIP]
为什么训练大语言模型(LLM)时必须有 Embedding 层?
关键在于:
反向传播
只能处理 数值向量,而原始文本是 离散符号。1.离散符号无法直接参与梯度下降
- 训练 LLM 的目标是
最小化损失函数
(比如交叉熵),通过反向传播
+梯度下降
更新参数。- 但文本输入是 token(单词、子词、字符)ID,比如
"apple"
→12345
。- 这些
整数 ID
是离散的
、无序的
标签,它们之间没有可导关系。
ID=5
和ID=6
之间没有“相近”的含义。- 如果直接把 token ID 喂进神经网络,梯度对输入 ID 不可导 → 无法更新参数。
2.Embedding 层的作用
Embedding 层本质是一个
可学习的查找表
(矩阵),把离散 tokenID 映射成 连续向量空间的点:
- 假设词表大小是
V
,embedding 维度是d
。- Embedding 层参数就是一个
V × d
的矩阵E
。- 输入 tokenID
i
→ 查表得到第i
行向量E[i] ∈ ℝ^d
。这样:
- Token 变成了 实数向量,可以输入 Transformer 的注意力、前馈层。
- Embedding 矩阵是
可训练参数
,可以通过反向传播更新,使得相似 token 的向量逐渐靠近(语义空间逐渐形成)。3.为什么必须有 Embedding 才能训练?
- 从数学角度:没有 embedding,就只能把 token 当作
整数
,而梯度对整数不可导
,训练停滞
。- 从表示角度:Embedding 提供了一个
连续向量空间
,使得离散语言符号能够以“可学习”的方式进入神经网络。- 从效率角度:相比
one-hot
(V
维向量),embedding 把 token 压缩成d
维,极大减少计算量。4.类比
- One-hot 编码:每个单词是一个超高维
稀疏向量
(梯度可传
,但计算开销大
)。- Embedding:相当于在训练开始时初始化一张“地图”,每个单词
先随机放置
在某个坐标,训练
过程中逐渐移动
到更合理的位置。- 没有 embedding:就像训练时只有“单词编号”,网络无法理解“编号之间的关系”,梯度也传不进去。
5.总结:
训练大语言模型必须有 Embedding,因为它是连接 离散符号空间 和 连续向量空间 的桥梁,使得
反向传播
和梯度下降
能够在语言数据上发挥作用。
更多信息,参考:AI 系列:反向传播 & 梯度下降
3. 关键原理
3.1. 注意力机制
编码器
-解码器
架构的 RNN
循环神经网络的工作原理,关键思想在于:
- 编码器
Encoder
,将整个输入文本,处理为一个隐藏状态
(记忆单元)。 - 解码器
Decoder
,使用该隐藏状态生成输出,隐藏状态视为一个嵌入向量,它捕获了输入文本的语义信息。
RNN 的限制在于:
- 在
解码阶段
RNN 无法直接访问编码器
的早期隐藏状态
,即,依赖当前隐藏状态来封装所有相关信息。这种设计可能导致上下文信息的丢失,特别是在依赖关系较长的复杂句子中,这一问题尤为突出。这是典型的长序列建模
问题,也引发人们设计出注意力机制
。
更多 RNN 细节:AI 系列: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}}}\]其中 \(z_{i}\) 是输入的每个分数(即未激活的原始值),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 架构中,层归一化通常应用于多头注意力模块的前后以及最终输出层之前。
1. LayerNorm 含义
在一个神经网络层的输出中,每个Token的 特征向量
(通常是隐藏层维度 (d) 的向量)都会做一次归一化。
其做法是:对某一层输出的 同一个 Token 的所有特征维度 进行均值
和方差
的标准化
处理。
公式如下: 均值为0、方差为1的正态分布
(减均值、除方差)
其中:
- \(x_i\):某一层输出向量中的第 \(i\) 个元素
- \(\mu = \frac{1}{d}\sum_{i=1}^d x_i\):该样本向量的均值
- \(\sigma^2 = \frac{1}{d}\sum_{i=1}^d (x_i - \mu)^2\):该样本向量的方差
- \(\epsilon\):防止除零的小常数
- \(\hat{x}_i\):归一化后的结果
接着会引入两个可学习的参数
:缩放系数 \(\gamma\) 和偏移量 \(\beta\),得到最终输出:
2.在 Transformer/LLM 中的作用
在 LLM(如 GPT)中,每个 Transformer Block 里都会使用 LayerNorm:
- 保证不同层、不同 token 的表示
分布稳定
,避免梯度爆炸
或消失
。 - 在训练中
加快收敛
,帮助模型更好地学习。 - 提升模型在大规模参数情况下的
数值稳定性
。
常见的使用方式有两种:
- Pre-LN:LayerNorm 在
残差连接
(Add)之前
(现在的主流做法,因为训练更稳定)。 - Post-LN:LayerNorm 在
残差连接
(Add)之后
(最初 Transformer 版本使用的)。
现在主流,全部是前置LayerNorm。
[Note]:
1.前置 Pre-LayerNorm,结构如下:(子层和残差是连续的,LayerNorm 在前/在后)
x -> LayerNorm -> Sublayer (Attention/FFN) -> Add(Residual) -> Output
- 即:在
进入子层之前
,先做 LayerNorm,再走注意力或 FFN,然后残差加回。- 优点:
- 更稳定,特别是当层数很深时(几十层甚至上百层的 GPT、LLaMA 等)。
- 保持
梯度流通
,梯度更容易传递到前面层,避免训练早期梯度消失。- 缺点:模型的表示在推理时有时不如 Post-LN 那么「干净」,但整体可控。
2.后置 Post-LayerNorm,结构如下:
x -> Sublayer (Attention/FFN) -> Add(Residual) -> LayerNorm -> Output
- 也就是说:
先经过子层
(Self-Attention 或 FFN),再加上残差
,最后做 LayerNorm。- 优点:结构直观,最早的 Transformer 论文就是这样。
- 缺点:训练深层网络时不稳定(梯度容易消失/爆炸)。
此外,补充一点 梯度消失、梯度爆炸信息:
- 梯度消失(vanishing gradients) 和 梯度爆炸(exploding gradients) 并不是同一件事情,但它们往往都出现在 深层神经网络 的训练过程中,是同一个根源(
链式法则
反复相乘)带来的两种极端现象。
a.梯度消失(Vanishing Gradients)
- 现象:反向传播时,梯度逐层变得越来越小,接近于 0。
- 原因:
- 激活函数饱和(Sigmoid/Tanh 在远离 0 的区域导数接近 0)。
- 权重较小,多层相乘后趋近于 0。
- 结果:
- 前层几乎得不到更新,模型只能训练后几层。
- 网络训练停滞,无法收敛。
b.梯度爆炸(Exploding Gradients)
- 现象:反向传播时,梯度逐层放大,数值非常大,甚至溢出为 NaN/Inf。
- 原因:
- 权重较大,多层相乘导致指数级放大。
- 深层网络的残差累计过大。
- 结果:
- 参数更新过度震荡,loss 不收敛,甚至直接崩溃。
c.二者的关系
- 梯度消失和爆炸是 同一问题的两个极端:
- 多层梯度连乘 → 若平均导数
< 1
,趋近 0 → 消失 - 若平均导数
> 1
,趋近 ∞ → 爆炸
- 多层梯度连乘 → 若平均导数
- 所以它们不是“同一件事”,但确实是
同一个根源
的两种结果
。
d.是否会同时发生?
- 同一条反向路径 上不会既消失又爆炸。
- 但在 不同路径/不同参数层 上,可以 有的梯度消失,有的梯度爆炸。
- 例如:在一个深层 RNN 里,前面的层梯度几乎消失,而靠后的某些层梯度却爆炸。
- 这种混合情况会让训练非常不稳定。
e.应对方法
- 使用合适的初始化(Xavier, Kaiming)
- 使用归一化(LayerNorm, BatchNorm, RMSNorm)
- 使用残差连接(ResNet, Transformer)
- 梯度裁剪(Gradient Clipping,专门对付爆炸)
- 改用不易饱和的激活函数(ReLU, GELU)
f.总结一句
- 梯度消失
≠
梯度爆炸,但它们是“同一根源”的两种不同结果。 - 一个网络,可能在
不同层
,同时出现消失和爆炸。
3.和 BatchNorm 的区别
- BatchNorm(批归一化):对一个 batch 中 同一维度 across 样本 做归一化(依赖 batch size),一般适用:计算机视觉(CV)、生成模型(CV 方向)、大 batch 训练任务(batch size > 16)
- LayerNorm(层归一化):对一个 Token 的 所有维度 做归一化(和 batch size 无关)。一般适用:LLM、推理、小 batch、可变长度序列。
因此:
- LLM 常用 LayerNorm,因为在 NLP 场景里,batch size 往往很小甚至为 1(如推理时),BatchNorm 效果不好。
- LayerNorm 更适合处理
可变长度的序列
,稳定性更强。
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
得到概率分布,再通过 交叉熵损失
计算损失,反向传播更新模型权重。
在大语言模型(LLM)的训练中,交叉熵损失(Cross-Entropy Loss)是最常用的目标函数,它衡量模型预测的概率分布
与真实标签分布
之间的差异。
下面从公式、直观含义和 LLM 场景的实际计算步骤三方面说明:
1. 公式
假设:
- 模型
预测的概率分布
为 \(p = (p_1, p_2, \dots, p_V)\),其中 \(V\) 是词表大小(vocabulary size),且 \(\sum_{i=1}^V p_i = 1\)。 真实标签分布
为 \(q = (q_1, q_2, \dots, q_V)\)。在语言模型中,通常采用 one-hot 向量,即目标词 \(y\) 的位置为 1,其余为 0。
交叉熵定义:
\[H(q, p) = - \sum_{i=1}^V q_i \log p_i\]由于 \(q\) 是 one-hot
,假设目标词索引是 \(y\),则简化为:
即:取模型对 正确词 的预测概率 (\(p_y\)),取对数,再加负号,并没有求和累加。
2. 直观理解
- 如果模型对正确词的预测
概率接近 1
,那么 \(-\log(p_y)\)很小
(接近 0),说明模型预测得准。 - 如果模型对正确词的预测
概率接近 0
,那么 \(-\log(p_y)\)很大
,模型会受到更强的惩罚。
换句话说,交叉熵损失
趋近0,就是让模型 更确信正确词,不断提高它的预测概率。
3. 在 LLM 训练中的实际计算
训练一个 LLM 时,输入一段文本序列:
\[x = (w_1, w_2, w_3, \dots, w_T)\]模型的任务是预测下一个词:
\[P(w_t \mid w_1, w_2, \dots, w_{t-1})\]所以:
-
每个位置 (t) 的损失是
\[\text{Loss}_t = -\log P(w_t \mid w_{<t})\] -
整个序列的平均损失是
\[\text{Loss} = \frac{1}{T} \sum_{t=1}^T \text{Loss}_t\]
4. 与 softmax 的关系
在实现中,模型输出的是 logits(未归一化的分数):
\[z = (z_1, z_2, \dots, z_V)\]经过 softmax 得到概率分布:
\[p_i = \frac{e^{z_i}}{\sum_{j=1}^V e^{z_j}}\]交叉熵损失实际计算为:
\[\text{Loss} = -z_y + \log\left(\sum_{j=1}^V e^{z_j}\right)\]这也是深度学习框架中常见的 CrossEntropyLoss
(logits, labels) 公式。
5.总结
在 LLM 中,交叉熵损失
就是“对正确词预测概率
的负对数
”。训练目标是最大化目标序列的似然(MLE),等价于最小化交叉熵。
[!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,造成数值计算的不稳定
性。如果我们对这些概率值取对数,然后相加,而不是直接相乘,我们可以避免这个问题。例如,对这三个值取
\[ln(0.2)+ln(0.1)+ln(0.05)≈−1.6094+(−2.3026)+(−2.9957)=−6.9077\]自然对数
(logarithm)后再相加
:虽然这个和也是负数,但它不会像直接相乘的结果那样接近于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
和负平均对数概率
这两个术语在计算上有关联,实践中经常互换使用。
4.3. 交叉熵损失,实践
问题:实际训练过程中,输入的是一个 token 序列,预测的只是下一个 token,这种情况下,是不是交叉熵损失函数,就没有求和的过程?
回答:不对,也有求和。
这个问题问得很关键,确实,在实现上会让人困惑:输入的是一个 token 序列,预测下一个 token,那交叉熵损失到底有没有「求和」?
1. 理论公式
我们刚才说的 交叉熵损失是:
\[\text{Loss}(x) = - \sum_{t=1}^T \log P_\theta(w_t \mid w_{<t})\]也就是说,对于一整段序列 \((w_1, w_2, \dots, w_T)\),它是 逐位置(token)预测的 log 概率的求和。
如果训练时的 batch 里有多条序列
,那还要对 batch 求和 / 平均
。
2. 实际训练实现上
在深度学习框架(如 PyTorch)里,一般是这样做的:
-
输入序列:
\[[w_1, w_2, \dots, w_T]\] -
模型输出 logits: 形状是 \((T, V)\),其中 V 是词表大小。 每一行是 softmax 前的 logit,表示「当前位置预测下一个 token」的分布。
-
交叉熵计算: 框架提供的
CrossEntropyLoss(logits, labels)
会:- 对
每个位置
(t
),取目标 token (\(w_t\)),计算 \(-\log P(w_t \mid w_{<t})\)。 - 默认会对所有位置的损失 求平均(
reduction="mean"
)。
- 对
3. 为什么看起来像“没有求和”?
- 如果你训练时,输入序列长度是 (T),框架自动会对 每个位置的预测计算交叉熵损失,然后 求平均,返回一个标量。
- 所以在代码层面,很多人感觉只是“预测一个 token 的交叉熵”,因为最终损失就是一个数。
但实际上,它内部是:
\[\text{Loss} = \frac{1}{T} \sum_{t=1}^T -\log P_\theta(w_t \mid w_{<t})\]只是框架
帮我们完成了求和 / 平均。
4. 特殊情况
- 如果你只输入
context
→预测下一个单词
,比如训练一个 语言建模单步任务(只预测最后一个 token),那确实 损失里就只有 1 项,没有序列内的求和。 - 但在大多数 LLM 训练中,是 对整个序列的所有 token 位置同时做预测(teacher forcing),所以有一个 token 级别的求和/平均。
5. 总结
- 理论上,
交叉熵损失
对 序列所有 token求和
(或平均
)。 - 实现中,深度学习
框架
会自动在序列维度
和batch 维度
上 做求和/平均,所以你看到的只是一个标量损失,好像“没有显式求和”。
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 结束后打印示例文本
附录
附录A. 联合概率分布、条件概率分布
llm 底层使用的是 联合概率分布、还是 条件概率分布?
这是一个很核心的问题。在 大语言模型(LLM) 的底层,本质上是基于 条件概率分布 的建模,而不是直接建联合概率。
A.1. 联合概率与条件概率的关系
- 给定一个文本序列 \(x = (x_1, x_2, \dots, x_T)\),它的 联合概率 是:
- 根据概率论的 链式法则(chain rule),
联合概率
可以分解为条件概率
的乘积
:
也就是说,模型不需要直接学整个联合分布,而是学每一步的条件概率
。
A.2. LLM 的做法
大语言模型(GPT、LLaMA 等)实际上是:
- 输入:前面的 token 序列 \((x_1, \dots, x_{t-1})\)
- 输出:下一个 token 的概率分布 \(\;P(x_t \mid x_1, \dots, x_{t-1})\)
训练时用 最大似然估计 (MLE):
\[\max_\theta \sum_{t=1}^T \log P_\theta(x_t \mid x_1, \dots, x_{t-1})\]这样,整个序列的联合概率
就间接得到了(通过链式展开)。
A.3. 为什么不用直接建联合概率?
- 联合概率空间维度太大,直接建模不可行。
条件概率
分布分解更自然,符合 自回归生成(autoregressive generation)的机制。- 解码(生成时)就可以逐步采样 token。
A.4. 总结:
- LLM 底层建模的核心是 条件概率分布 \(P(x_t \mid x_{<t})\)。
- 联合概率 \(\;P(x)\) 只是由这些条件概率
连乘
得到的结果。
附录B. LLM 本质是希望获取 联合概率分布
为什么希望用到
联合概率分布
呢?
这就涉及 “我们到底想从语言模型里得到什么”。
1. 联合概率分布的意义
如果我们真的有了完整的 联合概率分布 \(P(x_1, x_2, \dots, x_T)\),那意味着:
- 我们能对任意一个完整句子/文本算出一个
绝对概率
。 比如比较 “The cat sat on the mat.” 和 “The mat sat on the cat.” 哪个更可能。 - 我们能对 子序列、填空、改写 等各种任务做概率计算,因为所有的边缘分布、条件分布,都可以从联合分布里推出来。
- 在统计语言学里,“语言模型 = 联合分布的近似器”。如果联合分布有了,几乎所有 NLP 下游任务(翻译、问答、检索、摘要…)都能转化为概率问题。
2. 为什么现实里,不用直接建联合概率?
- 维度灾难:联合概率需要覆盖所有可能的 token 序列,复杂度接近 \(V^T\)(\(V\)=词表大小,\(T\)=长度)。直接建模不可能。
- 训练困难:要对整个分布做归一化(类似生成模型里的 partition function),计算不可行。
3. 条件概率的优势
通过 链式法则:
\[P(x_1, \dots, x_T) = \prod_{t=1}^T P(x_t \mid x_{<t})\]我们只需学 条件概率分布:
- 好训练(用
交叉熵
即可)。 - 好生成(
自回归
一步步采样)。 - 实际上,等价于间接地刻画了联合分布。
4. 直观比喻
- 联合概率 =
整个句子的分数表
,每一种表述的概率、很全,但存不下。 - 条件概率分解 = “一步步填空规则”,只要规则对了,就能拼出联合概率。
我们“希望”有联合概率分布
,是因为它能完整刻画语言世界,任何 NLP 任务都能从它推导出来。
但现实做不到,所以 LLM 退而求其次,用 条件概率分解 来间接实现,对联合分布的建模。
5. 具体例子:联合概率 得到 条件概率
假设我们要把英文句子 \(E = (e_1, e_2, \dots, e_m)\) 翻译成中文句子 \(C = (c_1, c_2, \dots, c_n)\)。
如果我们掌握了联合概率分布
:
那翻译任务就可以直接写成一个 条件概率最大化问题:
\[C^* = \arg\max_{C} P(C \mid E) = \arg\max_{C} \frac{P(E, C)}{P(E)}\]其中:
- \(P(E, C)\) 可以直接从联合分布得到
- \(P(E)\) 是常数,对每个候选译文都一样
- 所以我们只需要比较 \(P(E, C)\) 的大小,选概率最大的中文句子就是“最佳翻译”
附录C. 梯度下降与反向传播
附录D. 最大似然估计(Maximum Likelihood Estimation, MLE)
其实是交叉熵损失背后的统计学含义。
下文拆开解释一下「最大似然估计
(Maximum Likelihood Estimation, MLE
)」在 LLM 训练中的意义:
1. 似然(Likelihood)的含义
在概率模型里,给定参数 (\(\theta\)),模型定义了一个概率分布 (\(P_\theta(x)\))。
当我们观察到训练数据 (\(x\)) 时,似然函数就是:
\[L(\theta; x) = P_\theta(x)\]它表示 在参数 (\(\theta\)) 下,模型生成这批数据的概率有多大,即,x 表示输出结果。
2. 在 LLM 中的似然
对于一段文本序列:
\[x = (w_1, w_2, \dots, w_T)\]语言模型假设序列的联合概率分布
,是按条件概率分解
的(复合函数的链式法则
):
其中:
(P_\theta(w_t \mid w_{<t}))
由模型(Transformer)给出。- 训练的目标是:让模型在所有训练语料上,把
真实文本
出现的概率
尽可能调高
。
3. MLE 的训练目标
最大化似然,就是最大化所有训练样本的概率:
\[\theta^* = \arg\max_\theta \prod_{x \in D} P_\theta(x)\]通常对数化
(避免连乘数值下溢
接近0):
再代入链式展开:
\[\log P_\theta(x) = \sum_{t=1}^T \log P_\theta(w_t \mid w_{<t})\]4. 与交叉熵的关系
你会发现:最大化对数似然 = 最小化交叉熵损失。
因为每个位置的损失是:
\[\text{Loss}*t = -\log P*\theta(w_t \mid w_{<t})\]所以:
\[\text{Loss} = - \frac{1}{|D|} \sum_{x \in D} \sum_{t=1}^T \log P_\theta(w_t \mid w_{<t})\]这就是「最小化交叉熵损失」的公式:所有训练集合 \(D\) 内序列 \(T\) 每次下一个 Token 预测损失函数之和。 (llm 下,单次token 预测中,损失函数:概率的负对数,有没求和。)
5. 直观理解
- 似然最大化(MLE):让模型在参数 (\(\theta\)) 下,把训练数据“解释得更合理”,也就是让训练语料的出现概率最大化。
- 等价于交叉熵最小化:在实现时,我们不直接写似然,而是把它转化成损失函数(负对数概率),这就是交叉熵损失。
换句话说:LLM 训练目标
是让模型成为“在训练语料中,最擅长预测下一个词
的概率分布的参数集合”。
附录E. 框架下 CrossEntropyLoss 交叉熵计算细节
问题:DataLoader 参数中,常用的 Dataset 实现中,都是定义了一个滑动窗口,从中抽取出 input 和 label,并且 label 就是 input 平移一位之后的token序列,这是不是就说明:交叉熵损失,没有求和了?
回答:不对,也有求和;因为输入的是 token 序列,框架自动对序列求和了。
这个点你抓得很精准,分开来说清楚:
1. Dataset 层面
在 LLM 的自监督训练里,最常见的 Dataset 实现确实是:
- 定义一个 滑动窗口(长度 =
block_size
)。 - 从长文本里切片出一个片段
input_ids = [w1, w2, ..., w_T]
。 - 标签
labels = [w2, w3, ..., w_{T+1}]
,就是 input 整体右移一位。
这样,一个样本就是 一个完整的序列对 (input, label)
。
2. Loss 计算层面
虽然 Dataset 提供的 label 是一个 token 序列,但在 forward + loss
的过程中,模型还是会对 序列中的每个位置计算交叉熵:
# 伪代码
logits = model(input_ids) # shape: [batch, T, vocab_size]
loss = CrossEntropyLoss()(logits.view(-1, vocab_size), labels.view(-1))
这里的 CrossEntropyLoss
内部逻辑:
- 会逐 token 计算 \(-log p(w_t \mid w_{<t})\)
- 然后 默认对所有 token 求平均(或求和,再除以 token 数)
reduction="mean"
(默认):平均reduction="sum"
:总和reduction="none"
:保留逐 token 的 loss
所以,序列内部的求和是仍然存在的,只是框架帮你完成了。
3. 为什么会感觉“没有求和”?
因为:
- Dataset 只提供了
(input, label)
这一对序列,看上去像“整体预测一次”。 - 但在计算 loss 时,实际上是对
序列里每个位置
的预测误差
都算一遍,然后聚合。 - 最终返回的,只是一个 标量 loss,所以表面上看不到“求和”的过程。
4. 类比一下
如果序列长 5,labels = [w2, w3, w4, w5, w6]
,那 loss 实际上是:
只是 PyTorch 自动聚合了。
5. 总结
- Dataset 提供的
(input, label)
看似“一次预测”,但 loss 依然是对序列内所有 token 的交叉熵求和/平均。 - 所以,交叉熵的「求和」没有消失,只是被封装在
CrossEntropyLoss
内部了。
6. 扩展
问题:能不能在 Dataset 里就把「
labels = input.shift(-1)
」这个逻辑做成默认行为,这样调用 Dataset 时只管传入 input 序列,不需要用户自己在业务代码里手动构造 label?回答:完全可以,而且很多框架就是这么做的。
交叉熵损失只需要:
- logits(模型输出,对应 input 序列每个位置的预测分布)
- labels(目标序列,即 input 右移一位)
既然 labels 和 input 存在固定的变换关系,那 Dataset 层完全可以自动生成 labels,用户只传 input 就够了。很多 LLM 框架(比如 HuggingFace GPT2/LLama 的 model.forward
)已经是这么干的了。
实际情况:
-
HuggingFace Transformers 在调用
model(input_ids, labels=input_ids)
时,它的forward
内部会做 shift,对齐 input 和 label,然后自动计算交叉熵 loss。 这就是你说的「只传 input,label 自动生成」。 -
GPT 类实现(很多开源 repo) Dataset 里只存储
input_ids
,forward 时:labels = input_ids.clone() logits = model(input_ids) loss = F.cross_entropy(logits[..., :-1, :].contiguous().view(-1, vocab_size), labels[..., 1:].contiguous().view(-1))
也就是:input 用原始序列,labels = input.shift(-1),计算 loss 时再对齐。
-
某些 Dataset 实现 确实会让
__getitem__
返回(input_ids, labels)
,但这里的 labels 其实就是input_ids
的 shifted 版本,等于帮你提前算好了。
各种设计的差别:
- Dataset 返回 (input, label)
清晰直观
,灵活性高- 用户必须记得自己处理 shift,稍微冗余
- Dataset 只返回 input,labels 在 Dataset 内部或 model.forward 里自动构造
- 用户体验好,只传 input
- 避免忘记 shift 导致 bug
少了
一点灵活
性(比如有人想做特殊的 label masking,就得 override)
原文地址:https://ningg.top/ai-series-build-llm-from-scratch/