系列定位
本篇面向普通软件开发人员,不要求具备人工智能开发经验。我们将从一个只有两个参数的模型出发,推导损失函数、梯度下降、链式法则和反向传播,再从“模型是否容易训练”的角度,理解神经网络为什么会从多层网络、RNN 演进到 Transformer。
人类智能和人工智能的关系,在我看来更像鸟类飞行和飞机飞行的关系。飞机没有复刻鸟类,却通过不同的工程原理实现了载人飞行;人工智能也不是对人类智能的简单模仿,而是通过数据和计算完成过去需要人类才能完成的一部分任务。
原理不一样,AI 的很多表现也就没法拿人的经验去理解。把它当成人,会觉得它不太靠谱;比如我们在工程实践的时候有时候也会碰到以下这几个问题:
- 你问"这段代码是不是有 bug",它很可能真给你找出一个;然后你反驳一句,它又可能马上改口。这是因为模型训练时会用人的偏好来打分,而人往往更喜欢顺着自己说的回答。
- 让它用更新很快的框架写代码,它常常很笃定地写出旧版本的用法。这是因为模型的知识来自训练数据,训练一结束参数就固定了,之后出的新版本它没见过。
- 同一个问题问两次,答案可能不一样。这是因为模型每一步给出的是下一个 Token 的概率分布,再从中选一个,结果本身就带点随机。
从训练的角度看,这些表现都能找到解释。所以在用 AI 的同时,也值得了解一下它是怎么训练出来的,这样才能判断哪些事可以放心交给它,哪些结果得自己再核对一遍。
本篇不试图完整介绍神经网络的所有结构,而是集中回答一个问题:
神经网络为什么能够根据数据自动调整参数,它究竟是怎样训练出来的?
文章后半部分会接着讨论:当模型开始处理文本序列时,RNN 在数学和工程上遇到了哪些训练困难,Transformer 又是如何改善这些问题的。
如果你平时看到公式就想快速下滑,也不用紧张。接下来出现的每个公式,其实都在回答两个很朴素的问题:模型这次错了多少,以及下一步该往哪边改。
第一部分:一个神经网络究竟是怎样训练出来的
一、从“编写函数”到“训练函数”
传统软件通常可以抽象为:
输入 + 人工编写的规则 → 输出
例如,我们会根据登录状态决定进入首页还是登录页,也会根据接口返回结果显示内容、空页面或错误提示。这些分支和处理流程,都是开发者通过 if/else 等规则提前写好的。
从问题建模的角度,一个具有明确输入和输出的任务,可以被形式化为一个从输入空间 到输出空间 的映射:
这里,是输入,是输出, 表示从输入得到输出的整个计算过程。这通常被称为任务的输入—输出映射。
在登录跳转的例子中,假设 表示已经登录, 表示尚未登录。开发者可以直接写出:
if x == 1:
打开首页
else:
打开登录页
这段代码等价于一个分段函数:
现在再把其中的输入、函数和输出对应起来:
输入 x:用户的登录状态
函数 f:开发者编写的 if/else 判断和页面跳转逻辑
输出 y:首页或登录页
传统编程可以理解为:先确定输入和输出,再由开发者亲自把中间的函数 编写出来。if/else、循环、数据结构和各种算法共同构成了这个具体函数。
神经网络并没有改变 这个基本关系。它改变的是:中间的函数 不再由开发者逐条编写,而是通过数据和训练过程确定。
传统编程的过程是:
确定输入 x 和输出 y
↓
开发者编写具体函数 f
↓
程序执行 y = f(x)
神经网络训练的过程则变成:
提供多组输入 x 和正确输出 y
↓
先定义一个带有可调参数的函数 f(x; θ)
↓
训练过程根据预测误差调整 θ
↓
得到一个具体函数 f(x; θ*)
这里的 表示模型中所有可以调整的参数, 表示训练最终找到的那组参数。模型可以写成:
这里的 ŷ(读作 y hat)表示模型算出来的预测值,y 仍然表示样本给出的正确输出。两者之间的差距,就是后面要讲的"错了多少"。
模型结构规定可以从哪一类函数中寻找答案,不同的参数 对应其中不同的具体函数。训练的目的,就是根据数据选出一组更合适的参数 。
接下来的关键问题就是:只提供输入和正确输出,训练过程为什么能够确定中间的函数?
下面使用同一个例子贯穿整个训练过程。假设模型根据两个输入预测一个结果:
模型只有两个可训练参数:
现在提供三条样本:
| 样本 | 正确输出 | ||
|---|---|---|---|
| 1 | 1 | 0 | 2 |
| 2 | 0 | 1 | 3 |
| 3 | 1 | 1 | 5 |
这三条样本分别对参数提出约束:
满足这些约束的参数是:
于是模型得到具体函数:
我们没有直接把这个规则写进程序,只定义了模型形式,并提供了输入和正确输出。每条样本都会对同一组参数提出约束,多条样本共同决定哪些参数更合理。
但真实的数据总有噪声。假如第三条样本记录的是 y = 5.5 而不是 5,这三个约束就没有一组参数能同时满足,只能退而求其次,找一组让总体误差最小的参数。真实的神经网络还有几十亿个参数和非线性的结构,更不可能直接解方程。因此需要损失函数来衡量"总体误差",再用梯度下降在大量可能的参数中逐步寻找误差较小的一组:
模型结构规定:可以从哪些函数中寻找
训练数据规定:找到的函数应该满足哪些输入输出关系
损失函数衡量:当前函数距离这些要求还有多远
梯度下降负责:不断修改参数,找到一个总体误差较小的函数
因此,神经网络能够在没有具体规则代码的情况下解决问题,不是因为它不需要规则,而是因为样本把“什么结果是对的”变成数学约束,训练再从大量可能的函数中寻找一个总体上满足这些约束的函数。
传统编程由人直接写出函数;神经网络则由人定义函数范围和评价标准,再让数据与优化算法共同选出一个具体函数。
当然,从有限样本中选出的函数能否处理没见过的新输入,还取决于训练数据是否包含稳定规律,以及新输入是否与训练数据足够相似。这就是模型的泛化问题。
二、损失函数:如何量化“错了多少”
2.1 从三条样本得到三个损失
虽然我们已经知道理想参数是 ,但训练开始时模型并不知道。假设参数初始化为:
此时三条样本的预测值都是 。
模型已经给出了答案,但它自己并不知道答案好不好。我们需要给它一个“计分器”,这就是损失函数:预测得越离谱,分数越高;预测越接近目标,分数越低。
对第 条样本,使用最简单的平方误差:
三条样本的预测和损失为:
| 样本 | 预测值 | 正确输出 | 损失 |
|---|---|---|---|
| 1 | 0 | 2 | |
| 2 | 0 | 3 | |
| 3 | 0 | 5 |
2.2 一批样本只产生一个平均损失
训练不能只改善其中一条样本,而要观察这一批样本的总体表现。因此,对三条损失取平均:
如果一个批次包含 条样本,更一般的平均损失写成:
其中, 表示单条样本的损失, 表示当前批次的平均损失。如果 等于训练集的样本总数,就是对整个训练集取平均;真实的训练通常使用更小的批次,多次更新参数。
训练神经网络,本质上就是寻找一组使损失尽可能小的参数:
- 这里用 表示整体训练目标。
- 表示:“最低损失是多少?”
- 表示:“哪一组参数能让损失最低?”
因此,公式左边写的是参数 ,右边使用 。真实训练通常只能找到一组让损失足够小的参数,并不保证它一定是全局最优解。
训练就是在一个巨大的参数空间里,寻找一组能够让平均损失尽可能小的参数。
三、从局部最优到梯度下降
3.1 先确定目标:寻找局部最优
上一节已经把训练目标定义为:寻找一组能让平均损失 尽可能小的参数。
如果损失只与一个参数 有关,我们可以把 画成一条高低起伏的曲线。整条曲线上最低的点是全局最优;某个点只要比它附近的点都低,就可以看成一个局部最优。
对真实的神经网络来说,损失取决于成千上万甚至数十亿个参数。我们无法穷举所有参数组合后找出整片空间的最低点。因此,实际训练采用了一个更现实的思路:
先不试图一次找到全局最优,而是从当前参数出发,反复寻找附近能让损失下降的方向。
这样,问题就从“全局最低点在哪里”,变成了“我现在往左走一点,还是往右走一点,损失会更低”。要回答这个局部问题,就需要导数。
3.2 导数:怎样判断附近的下降方向
现在回到训练问题。假设损失只与一个参数 有关,可以写成 。我们站在当前参数$ 的位置,希望判断下一步应该把它调大,还是调小。
先试着把参数改变一个很小的量 ,损失随之发生的变化是:
但只看 还不够,因为参数改动得越多,损失通常也会变化得越多。为了衡量“参数每变化一点,损失会变化多少”,需要计算二者的比值:
如果让 不断变小,直到无限趋近于 ,这个比值的极限就是损失函数在当前位置的导数:
所以,导数并不是为了增加一个新的数学概念,而是为了回答训练中最直接的问题:在当前位置附近,参数稍微变化一点,损失会朝哪个方向变化,以及变化得有多快。
写成一般的一元函数 ,导数的通用定义就是:
从图像上看,导数的数值等于函数曲线在这一点的切线斜率。切线只是帮助我们观察斜率的几何工具,训练真正使用的是导数的数值。
当 足够小时,可以近似认为:
也就是:
现在我们讨论的是“把 增大一点”,所以 。这时, 的正负就由导数 决定:
- 如果 ,那么 ,说明增大 会让损失上升;
- 如果 ,那么 ,说明增大 会让损失下降;
- 如果 ,那么一阶变化近似为零,参数暂时没有明确的下降方向。它可能已经到达极小值,也可能位于极大值或其他平坦位置;如果此时损失已经足够小,梯度接近零就可以作为训练趋于收敛的信号之一。
因此,如果希望损失下降:
- 导数为正,就应该减小 ;
- 导数为负,就应该增大 。
也就是说,参数需要沿导数的反方向移动。这也就是“梯度下降”这个名称的来源。
图中红色切线的斜率表示当前位置的导数,绿色箭头表示负导数方向。随着参数接近最低点,导数和更新幅度都会逐渐减小。
3.3 从导数到梯度
实际模型通常不只有一个参数。例如当前例子的平均损失:
当我们只观察其中一个参数的变化,并把其他参数暂时看作常数时,得到的就是偏导数:
把损失函数对所有参数的偏导数组合起来,就得到了梯度:
梯度不是模型需要调整的对象,而是一个方向向量:它描述了损失相对于各个参数增长最快的方向。
因此,如果希望损失下降,就可以沿梯度的反方向调整参数。
在二维参数空间中,梯度不再是一个数字,而是由两个偏导数组成的方向向量。橙色轨迹展示了参数沿负梯度方向逐步接近极小值的过程。
图中的同心等高线来自为了教学而选用的简单碗形函数;真实神经网络的等高线通常更不规则,但“同一条等高线上的损失相同”以及“梯度垂直于等高线”的关系仍然成立。
二维等高线更容易观察参数的移动路径,三维曲面则把损失画成了一片有高低起伏的地形:暖色山坡代表高损失,蓝色谷底代表低损失。橙色小球是当前参数,紫色轨迹记录它走过的路线;红色箭头指向最陡上坡,绿色箭头指向负梯度,也就是训练真正采用的下坡方向。
3.4 为什么负梯度能够降低损失
前面已经得到:
为了让 与导数符号相反,选择:
其中 是学习率,负号保证参数沿导数的反方向更新。
因为 ,所以完整的参数更新公式是:
将它代回前面的近似关系:
因为:
所以只要学习率足够小,使一阶近似有效,就有 ,新的损失通常会小于原来的损失。
推广到多个参数:
这就是最基本的梯度下降。
这里有两个容易混淆的概念:
- 梯度决定局部的方向和敏感程度;
- 学习率决定每一次沿这个方向走多远。
学习率太小,训练会非常缓慢;学习率太大,则可能跨过最低点来回震荡,甚至让损失越来越大。
三条轨迹使用相同的损失函数和起点,只改变学习率。学习率决定每次更新的幅度,因此会直接影响收敛速度和训练稳定性。
需要注意的是,真实神经网络的损失函数定义在包含大量参数的高维空间中,不再是一条简单的抛物线。梯度下降也不保证一步找到全局最优解,而是通过大量局部更新,逐渐寻找一组足够好的参数。
四、用一次训练理解链式法则与反向传播
第二节已经完成当前批次的前向计算:三个预测值都是 ,平均损失约为 。接下来要回答的是:这两个参数分别应该怎样调整?
4.1 一条样本的损失怎样追溯到参数
对第 条样本,计算路径是:
(w₁,w₂) → ŷᵢ → Lᵢ
参数不会直接产生损失,而是先影响预测值,再由预测值影响损失。以 为例:
其中:
所以:
为什么两段影响要相乘?因为在变化量足够小时:
把第二个式子代入第一个式子,两段局部影响自然相乘。这套沿计算路径逐段相乘的规则,就是链式法则:
同理:
4.2 三条样本怎样共同决定一次更新
把当前参数 代入,三条样本分别产生以下梯度:
| 样本 | ||
|---|---|---|
| 1 | ||
| 2 | ||
| 3 |
训练根据整批样本的平均损失更新参数,因此也要对三条样本的梯度取平均:
所以当前批次的梯度是:
这一步很关键:每条样本分别对参数提出调整意见,训练把这些意见平均起来,再统一更新一次参数。
4.3 更新参数,再确认平均损失下降
假设学习率为 :
新参数下,三条样本的预测值变成 、 和 ,新的平均损失为:
一次参数更新后:
模型还没有到达理想参数 ,但平均损失已经显著下降。继续输入更多批次并重复更新,参数就会逐步靠近较优区域。
4.4 反向传播到底是什么
刚才的计算包含两个方向:
- 前向传播:使用当前参数计算每条样本的预测值和平均损失;
- 反向传播:从平均损失出发,沿计算路径反向应用链式法则,得到每个参数的梯度。
反向传播并不是另一套独立的数学规律。它会复用公共的中间结果,高效完成大量链式法则计算:
反向传播,就是沿计算图反向应用链式法则,高效得到所有参数的梯度。
现代深度学习框架会在前向计算时记录运算关系,再自动完成反向传播,但背后的数学原理没有改变。
下面的动画用第3条样本把前向和反向两个方向走一遍,对应前面表格的第三行:
真正的神经网络只是计算路径更长。例如:
第一层参数 对损失的影响,需要沿整条计算链传播:
网络越深,计算链越长,能表达的函数越复杂;但链式法则里连乘的项也越多,梯度可能越乘越小或越乘越大,这会给训练带来新的困难。
五、把一次更新扩展成完整训练
第四节完成的是一个 Batch 的一次参数更新。真实训练会继续读取下一个 Batch,重复同样的过程:
全部训练样本
↓ 划分为多个 Batch
一个 Batch:前向传播 → 平均损失 → 反向传播 → 更新一次参数
↓
处理下一个 Batch
↓
所有 Batch 处理完成 = 1 个 Epoch
↓
重复多个 Epoch,直到损失趋于稳定或满足停止条件
其中:
- Batch:一次参与前向传播、反向传播和参数更新的一组样本;
- Epoch:模型完整处理一遍训练集中的全部样本;
- Optimizer:根据梯度更新参数的方法,例如 SGD、Adam;
- Learning rate:控制每次参数更新的幅度。
不同 Batch 包含的样本不同,因此每个批次的损失不一定都比上一个低。训练关注的是一段时间内的总体趋势——训练损失是否下降,以及验证集损失或目标指标是否继续改善。
接近较优区域后,损失常见的表现有两种:
- 趋于收敛:损失下降越来越慢,参数更新也越来越小;
- 低位波动:由于批次抽样带来的噪声,损失在一个较低范围内上下震荡,不再明显改善。
低位小幅波动可以是正常现象;如果损失大幅震荡甚至持续上升,通常意味着学习率过大或训练不稳定,而不是已经成功收敛。
训练不需要等到所有梯度精确等于零才结束。实际工程通常综合观察:
- 梯度已经很小,参数更新幅度也很小;
- 训练损失在连续多轮中几乎不再下降;
- 验证集损失或目标指标长期没有改善,此时可以提前停止训练,避免继续过拟合;
- 模型已经达到预先设定的准确率、损失或其他业务指标;
- 已经达到设定的训练轮数、Token 数、时间或计算预算。
梯度接近零只是趋于收敛的信号之一。大模型预训练也常在达到预定数据量或计算预算后停止,而不是等待所有梯度变成零。
5.1 为什么神经网络需要张量
前面的例子只有两个参数,真实神经网络却需要同时处理大量数据和参数。为了提高计算效率,工程中会把标量组织成向量、矩阵和更高维数组,统称为张量。
一个数字、一个向量、一个矩阵,分别可以看成 0 阶、1 阶和 2 阶张量;更高维数据也可以用同一概念表示。
以前面的三条样本为例。三条样本的输入可以排成一个 3×2 的矩阵,两个参数排成一个向量:
三个预测值不用一条一条算,一次矩阵乘法就能全部得到:
代入 4.3 节更新后的参数 ,得到 ,和前面一条条算的结果一样
再比如,一批文本的 Embedding 可以使用三维张量表示:
其中:
是批次大小;
是序列长度;
是每个 Token 的向量维度。
张量并没有改变训练的基本原理。它只是让大量样本和参数能够通过矩阵乘法并行计算。
第二部分:神经网络如何一路演进到大模型
六、从多层网络到序列模型
6.1 多层网络解决了什么问题
单纯的线性模型只能表达线性关系。多层神经网络通过在线性变换之间加入非线性激活函数,可以拟合更加复杂的函数:
只要整个计算过程可导,就可以通过反向传播训练多层网络中的参数。其中 σ 是激活函数,例如 ReLU
1986 年,Rumelhart、Hinton 和 Williams 在论文 Learning representations by back-propagating errors 中系统展示了如何用反向传播训练多层网络。这篇论文让反向传播广为人知,成为训练神经网络的标准方法。
这形成了深度学习最基本的模式:
增加网络层数和参数
→ 获得更强的表达能力
→ 使用反向传播计算梯度
→ 使用优化器更新参数
但是,当模型开始处理文本、语音、时间序列等有顺序的数据时,普通前馈网络(也就是上面这种多层网络)的结构就显得不够自然。
这并不是说前馈网络完全不能处理序列。假设一句话固定包含三个 Token,我们可以把它们的向量拼接起来:
再整体交给一个多层前馈网络:
这样做理论上也能学到不同位置之间的关系,但有两个问题:
- 长度写死了:三个 Token 拼接后输入是 3 × D 维,第一层参数也按 3 × D 来定;换成五个 Token,输入变成 5 × D 维,原来的参数就用不了。
- 同一个 Token 在不同位置要分别学:位置 1、2、3 各对应参数矩阵中一块固定区域。同样是"不好",在"不好吃"里出现在开头,在"这家店不好"里出现在结尾,用到的是两块不同的参数,得分别学一遍。
那能不能一次只输入一个 Token,每个 Token 都用同一组参数计算?这样前面两个问题都解决了。但前馈网络每次计算相互独立,前后没有联系:读到"小猫吃饱了,它很开心"里的"它"时,网络并不知道这个"它"指的是小猫。
所以序列模型需要的是:每一步都用同一组参数,同时把前面的信息带到下一步。RNN 就是为此设计的
6.2 RNN:让模型拥有状态
循环神经网络 RNN 使用一个不断更新的隐藏状态处理序列:
其中:
是第 个位置的输入;
是上一个位置的隐藏状态;
是融合当前输入和历史信息后的新状态;
是激活函数,通常用 tanh
是不同时间步共享的参数。
从软件开发的角度,可以把它理解为:
state = initialState
for token in sentence:
state = update(state, token)
可以把 RNN 想象成一个边读文章、边在便签上做摘要的人:每读一个 Token,就把新内容和旧便签合并,再把更新后的便签传给下一步。
RNN 因此能够处理不同长度的序列,并把前面的信息逐步传递到后面。它的重要意义在于:神经网络能够使用同一组共享参数处理不同位置的输入,并表达带有时间顺序的依赖关系。
6.3 RNN 是怎样训练的
RNN 在不同时间步共享参数。训练时,可以把循环在时间轴上展开:
x₁ x₂ x₃ x₄
↓ ↓ ↓ ↓
h₁ → h₂ → h₃ → h₄ → Loss
然后像训练一个很深的网络一样,从最后的损失开始反向传播。这个过程通常称为随时间反向传播:
Backpropagation Through Time,简称 BPTT。
RNN 能够被训练,但序列越长,展开后的计算链也越长,问题随之出现。
七、RNN 的数学短板
7.1 梯度消失与梯度爆炸
问题也藏在这张不断改写的便签里:一句话越长,信息被转述的次数就越多。反向传播时,梯度也要沿同一条长链一路传回去。
假设较晚时刻的损失 需要影响较早时刻的状态 。为了突出核心问题,暂时忽略矩阵乘法方向等细节,根据链式法则可以写成:
对于:
局部梯度包含:
其中 表示激活函数之前的线性计算结果。
我们以以 tanh 为例, 进行计算
问题出现在连续相乘:
由于:
因此:
特别是当 很大时:
所以:
这种情况下梯度每经过一个时刻,就会乘上一个接近 的数,连续相乘后很容易消失。
整体不一定小于 1,因为 可能很大。关键在于:每往回传一步,乘的都是同一个 (参数共享),连乘下来就像 的 n 次方,结果要么越来越小,要么越来越大:
-
长期小于 1,梯度消失:比如 。离得越远的 Token,对参数更新的影响越小,模型很难学到远距离的依赖。
-
长期大于 1,梯度爆炸:比如 。梯度迅速增大,参数更新剧烈,训练不稳定。
7.2 长距离信息需要经过很长的路径
如果第 100 个 Token 需要使用第 1 个 Token 的信息,传统 RNN 必须经过中间 99 次状态更新。
这意味着:
- 信息可能在多次状态更新中逐渐丢失;
- 梯度也必须经过一条很长的链式乘法路径;
- 序列越长,学习远距离关系通常越困难。
7.3 固定长度状态形成信息瓶颈
RNN 试图把已经读取的历史信息压缩到固定维度的隐藏状态 h_ 中:
无论前文有多长,都需要被压缩进同一个向量。随着序列变长,模型很难无损地保存所有重要信息。
7.4 顺序依赖限制并行训练
由于:
所以必须先计算 ,才能计算 。不同时间步无法在训练时完全并行:
h₁ 完成后才能计算 h₂
h₂ 完成后才能计算 h₃
h₃ 完成后才能计算 h₄
这不仅是数学上的依赖关系,也直接限制了 GPU 的并行计算能力。
7.5 LSTM 和 GRU 的改进
普通 RNN 只有一个隐藏状态 h_,新信息会不断覆盖旧信息。序列较长时,早期信息很难一直保留下来;同时,梯度需要沿时间步连续传播,容易出现梯度消失或梯度爆炸。
1997 年提出的 LSTM 为这张“便签”增加了一条长期记忆通道,并通过门控机制决定哪些信息需要保留、哪些可以遗忘、哪些需要写入。它提高了模型保存长期信息的能力,也显著缓解了梯度消失。
三个门和一条记忆通道
LSTM 使用细胞状态 携带长期记忆。遗忘门、输入门和输出门负责控制信息的流动,所有门都由当前输入 和上一时刻的隐藏状态 共同决定。
- 遗忘门
的每个元素都在 到 之间,用来控制旧记忆 中的每个维度保留多少。 越接近 ,表示遗忘得越多;越接近 ,表示保留得越完整。
- 输入门
输入门决定候选信息中的哪些部分需要写入长期记忆。
- 候选状态
候选状态表示根据当前输入生成的新信息,是“准备写入”细胞状态的内容。
- 输出门
输出门决定当前记忆中的哪些部分可以对外暴露,形成当前隐藏状态:
输出门不会直接修改 ,而是控制下游能够看到多少当前记忆。
选择性遗忘和写入
LSTM 对细胞状态的更新可以概括为:
其中,第一项表示按比例保留旧记忆,第二项表示按比例写入新的候选信息, 表示逐元素相乘。两部分通过加法合并,使 LSTM 可以在保留旧信息的同时写入新信息,而不必在每个时间步完全重写整个状态。
为什么能够缓解梯度消失
普通 RNN 的梯度需要沿时间步连续相乘。从时刻 反向传播时,有:
如果这些乘数的模持续小于 ,则可能发生梯度爆炸。
LSTM 的细胞状态由旧记忆和新信息相加得到。反向传播经过这个加法节点时,梯度会原样传给两个分支,其中一条就是沿细胞状态直接往回的通路,不经过 tanh,也不需要和同一个权重矩阵反复相乘
只看这条直接通路,有:
跨越多个时间步时:
在实际训练中,遗忘门偏置 通常会被初始化为正数,使模型在训练初期更倾向于保留旧状态。随后,模型再根据输入和隐藏状态,学习哪些时间点、哪些记忆维度需要遗忘,并将相应的 调低。这种“默认保留、选择性遗忘”的初始化和训练方式,使较多时间步上的遗忘门能够保持较大取值(接近1),从而减缓长期梯度的衰减。因此,LSTM 并没有彻底消除梯度连乘,而是通过选择记忆和遗忘,让其中一条梯度通路的乘数可以接近 ,从而显著减缓梯度消失。
后来提出的 GRU 沿用了门控思想,但将细胞状态与隐藏状态合并,并把三个门简化为更新门和重置门,在保留长期记忆能力的同时减少了参数量和计算开销。
门控机制仍然没有解决什么
不过,无论是 LSTM 还是 GRU,门控机制主要缓解了“记不住”的问题,并没有解决“必须按顺序处理”的限制。当前状态 仍然依赖前一时刻的状态 ,因此各时间步只能依次计算,难以充分并行。
当序列很长时,早期信息仍然要经过许多时间步才能影响后面的位置,信息传播路径依然较长。换句话说,LSTM 和 GRU 改善了 RNN 的记忆能力,却保留了循环结构带来的顺序计算和长路径问题。这也成为后来注意力机制和 Transformer 重点解决的问题。
八、Transformer 如何改善训练
如果说 RNN 像排队传话,那么 Transformer 更像把所有 Token 拉进了同一个群聊:它放弃了循环结构,改用自注意力,每个 Token 都可以直接查看其他 Token,并判断哪些信息与自己最相关。
Transformer 出自 2017 年的论文 Attention Is All You Need。标题的意思是:处理序列不一定要靠循环,用注意力加矩阵乘法就够了。不过transformer不止有注意力还有残差连接、归一化、位置编码等设计。
8.1 从 Token 到向量
文本首先被切分成 Token,再通过 Embedding 映射为向量:
其中 d 是向量的维度。把长度为 n 的序列组合成矩阵:
自注意力本身不区分顺序:把 Token 打乱,每个 Token 算出来的结果不变,只是跟着换了位置。所以还要给每个位置加上位置编码,让模型知道 Token 的先后
8.2 自注意力的数学形式
先看一个例子:“小猫吃饱了,它很开心”。看“它”这个 Token 是怎么知道自己指的是谁的。
左边是注意力权重矩阵 。每一行是一个 Token(Query,谁在看),表示它对每个 Token 的关注度;每一列也是一个 Token(Key,被看的),表示它对其他 Token 的影响力。每一行加起来等于 1。比如“它”这一行,对“小猫”的关注度权重是 0.6。
中间的 是每个 Token 准备交出去的信息(Value)。“它”这一行乘上 ,就是把每个 Token 的 Value 按权重加起来,得到“它”的新向量。这个新向量里一大半来自“小猫”,而且是一步直接拿到的,中间隔了几个词都一样。
那 0.6 这个权重是怎么来的?
每个 Token 的向量 分别乘上三个可训练矩阵,得到 Query、Key 和 Value,所有位置共用同一组矩阵:
计算 Token 两两之间的相关分数:
其中 是 Key 向量的维度。
再通过 Softmax 将相关分数转换为权重:
图中“它”这一行和“小猫”这一列做点积,除以 ,再经过 Softmax,就是 0.6(经由训练好的矩阵最终训练出来的值,这里是一个假设)。
最后按照权重汇总 Value:
合在一起就是:
上面的例子只算了“它”这一行;写成矩阵,所有 Token 一次全部算完。
最终用来预测下一个 Token。这里省略了中间的前馈网络、多层堆叠等步骤,只看注意力输出怎么变成词表上的分数:
可以按照以下的方式理解这个过程:
- Query:当前 Token 正在寻找什么信息;
用来学习token在不同维度的想要寻找的特征规律
- Key:当前Token 可以提供什么信息;
用来学习token在不同维度的可以提供的特征规律
- :Token 之间的相关程度(也可以理解为不同的token之间的不同维度的特征规律是怎么互相作用的);
- Softmax:将相关程度转换成权重;
- Value:每个 Token 实际交出去的不同维度下的特征规律,和注意力矩阵结合以后可以理解为按照权重汇总实际信息。
用来学习输入token身上哪些特征维度可能对完成当前任务有用,有了注意力权重矩阵以后我这token的特征应该怎么传递出去
- :用来学习上下文特征到输出 Token 的映射的规律;
除以 是为了控制点积的数值范围。点积是 项相乘再相加,维度越大,结果通常越大。分数一大,Softmax 就会把几乎全部权重压到一个 Token 上,其他 Token 的权重接近 0,梯度也跟着变得很小。除以 把分数拉回合适的范围,训练更稳定。
8.3 Transformer 缩短了信息和梯度路径
在 RNN 中:
Token 1 的信息需要经过多个状态才能影响 Token 4。
在自注意力中:
因为:
其中 是权重矩阵 第 i 行第 j 列的值, 是位置 j 的 Value 向量
位置 可以在一次注意力计算中直接汇总位置 的信息。两个位置之间的信息传播路径不再随着它们在序列中的距离线性增长。
这并不意味着 Transformer 不再需要反向传播,而是意味着反向传播时,重要 Token 之间可以拥有更短的梯度路径。
动画上半部分展示梯度沿 RNN 时间链逐段传播并不断缩小;下半部分展示自注意力在一层内部为远距离 Token 建立直接联系。
8.4 Transformer 更适合并行训练
RNN 的时间步存在严格的先后依赖,而 Transformer 的注意力主要由矩阵乘法组成:
训练时,序列中多个 Token 的计算可以并行执行。对于只能看到前文的语言模型,可以使用掩码遮住未来 Token,而不需要恢复 RNN 的顺序循环。
并行性使 Transformer 能够更充分地利用 GPU,也更容易扩大:
- 训练数据规模;
- 模型参数规模;
- 每次训练使用的计算量。
8.5 残差网络
网络加深以后,还会遇到一个反直觉的现象:层数更多,连训练集上的误差都可能更高。理论上,更深的网络至少可以让多出来的层什么都不做,复制浅层网络的结果;但“什么都不做”这件事,普通网络偏偏不容易学会。
2015 年,何恺明等人提出了残差网络(ResNet)。它没有要求每个模块从头学习完整的目标映射 ,而是把问题改写成:
网络只需要学习“在原有输入上还要改变多少”,也就是残差 。如果这一层暂时没有学到有用的变化,只要让 ,输入就能近似原样通过。
这个加法对反向传播同样重要。先看没有残差的普通网络:每一层是 ,梯度从第 L 层传回第 l 层,要一路连乘:
这和第七节 RNN 的情况一样:每一项只要长期小于 1,层数一多,连乘下来就趋近于 0,梯度消失。
加上残差以后,每一层变成:
求导就是:
其中 I 是单位矩阵,可以先把它理解成 1。梯度里始终有一项 I 是原样传回来的,不经过任何连乘;只有后面那一项恰好把它抵消掉,梯度才会消失,而这几乎不会发生。梯度相当于多了一条不穿过复杂变换的直通路。残差连接并不是从数学上保证梯度永远不会消失,但它显著改善了深层网络中的信息和梯度传播,让上百层网络真正变得可训练。
用简单的例子来验证一下:
从每层的导数出发。把每层的简记为 a、b、c(当成数来算):
把残差那一项乘开:
(1+a)(1+b)(1+c) = 1 + (a+b+c) + (ab+ac+bc) + abc
普通网络只剩最后那个 abc,而残差网络展开后,开头多出一个单独的 1,它就是公式里的 I。
代几个数看看:
只有每层的 恰好凑成 −1,把那个 1 抵消掉,梯度才会消失;真实网络里一批样本同时凑成这样,几乎不可能。
残差网络让上百层的网络真正变得可训练,这篇论文后来在《Nature》的统计中成为 21 世纪综合被引第一的论文。
残差连接后来不只用于图像网络,也成为 Transformer 的基础结构。它让信息可以绕过当前层,同时为梯度保留更直接的传播路径。Layer Normalization 则像每一层的“稳压器”,用于控制中间数值的尺度,让深层训练更稳定。
因此,从训练角度看,Transformer 的优势不只来自注意力,而是几种设计共同作用:
-
自注意力缩短远距离信息路径;
-
矩阵运算支持 Token 级并行;
-
残差连接给信息和梯度保留近路;
-
归一化提高训练稳定性。
九、总结
9.1 一条以训练问题为主线的发展脉络
从训练的角度看,第二部分讲的这条演进路线,每一步都是在解决上一步留下的训练问题:
- 多层网络提升了模型的表达能力;
- 反向传播让多层参数可以被训练;
- RNN 让神经网络能够处理序列;
- RNN 的长梯度路径和顺序依赖,限制了长期关系学习与并行训练;
- LSTM 和 GRU 缓解了部分梯度问题;
- Transformer 通过自注意力、并行计算、残差连接和归一化,进一步改善了大规模序列模型的训练;
- 大语言模型则在 Transformer 的基础上,通过更大的数据、参数和计算规模学习语言规律。
9.2 大语言模型的训练
以自回归语言模型为例,它学习的是:
也就是根据前面的 Token,预测下一个 Token 的概率分布。
对于一段 Token 序列,常见的训练损失可以写成:
如果模型给正确 Token 的概率越高,损失就越小;给正确 Token 的概率越低,损失就越大。
完整训练过程仍然是:
大语言模型可能拥有数十亿甚至更多参数,但最基本的参数更新仍然可以写成:
实际训练通常会使用 Adam 等更复杂的优化器,也会加入学习率调度、权重衰减、梯度裁剪和混合精度训练等工程方法,但它们仍然建立在梯度、反向传播和迭代优化之上。
预训练之后,还可以使用指令数据继续训练模型,或者根据人类及模型反馈进行偏好对齐。数据和目标函数会发生变化,但“前向计算—损失—反向传播—参数更新”的核心循环没有改变。
9.3 训练的核心
从两个参数的线性模型到拥有海量参数的大语言模型,模型的规模和结构发生了巨大变化,但训练的基本逻辑始终一致:
- 使用带参数的函数计算预测结果;
- 使用损失函数衡量预测与目标之间的差距;
- 使用链式法则和反向传播计算所有参数的梯度;
- 使用优化器沿着使损失减小的方向更新参数;
- 在大量数据上不断重复这个过程。
神经网络所谓的"自动训练",并不是模型凭空产生了智能,而是我们先将任务抽象成一个可计算、可优化的数学系统,再通过数据和优化方法不断修正参数。
人工智能并不是对人类智能的简单复刻。它更像一套以数据、数学优化和工程系统为基础的能力生成机制。理解训练过程,能够帮助我们更准确地理解大模型为什么有效,也能帮助我们认识它的局限:模型最终学到什么,仍然受到训练数据、目标函数、模型结构和计算资源的共同约束。
回头看这条路,有一个问题始终贯穿其中:
模型拥有更强表达能力以后,我们是否仍然能够稳定、高效地训练它?
附录
10.1 神经网络的理论黑盒
神经网络中很多关于“为什么有效”的原理分析,都带有一定的马后炮性质:研究者往往先在实验中发现一种结构或训练技巧非常有效,再尝试用数学和直觉解释它。后续更严格的控制实验有时会发现,方法仍然有效,但最初给出的解释并不是主要原因。
这并不是说所有理论都是事后编造的,更不是说数学分析没有用。真正需要区分的是两件事:
“它在实验中确实有效”与“我们已经准确理解它为什么有效”,并不是同一件事。
A.1 Batch Normalization:一个经典的例子
2015 年,Sergey Ioffe 和 Christian Szegedy 提出了 Batch Normalization,简称 BatchNorm。它会在训练时根据一个批次的数据,对网络中间层的数值进行标准化:
其中, 和 分别是当前批次的均值和方差。
BatchNorm 的实践效果非常好:它能够支持更高的学习率、降低模型对参数初始化的敏感程度,并明显加快训练。原论文将其主要原因解释为:它减轻了网络内部数据分布不断变化的问题,也就是所谓的 Internal Covariate Shift(内部协变量偏移) 。
这个解释听起来十分合理,也随着 BatchNorm 的广泛使用流传了很多年。
原论文:Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift
A.2 方法仍然有效,但原来的解释可信度下降
2018 年,Shibani Santurkar 等研究者重新检验了这个解释。他们通过实验主动扰乱 BatchNorm 后的数值分布,让所谓的“内部协变量偏移”重新出现。结果发现,即使中间数据的分布仍然不稳定,加入 BatchNorm 的网络依旧训练得很好。
他们还观察到:使用 BatchNorm 后,损失函数在参数空间中通常会变得更加平滑,梯度也更加稳定、更加可预测。因此,他们提出了另一种解释:BatchNorm 的重要作用可能不是简单地稳定中间数据的分布,而是改善了优化问题本身,让梯度下降更容易进行。
后续论文:How Does Batch Normalization Help Optimization?
A.3 这个故事说明了什么
BatchNorm 从来没有因为原始解释受到挑战而失效。模型照样能够训练得更快,只是我们对它“为什么有效”的认识发生了变化。
这个故事说明了三件事:
- 实践结果通常先于完整理论。 很多神经网络结构,都是先被实验验证有效,再逐步补充解释。
- 方法有效,不代表第一个解释一定正确。 一个方法也可能同时通过多种机制发挥作用。
- 今天的解释可能只是当前最好的理解。 对注意力、残差连接、向量表示等机制的解释,可以帮助我们建立直觉,但不应被当成永远不会变化的最终答案。
所以,在介绍神经网络原理时,比较准确的态度不是“这些解释都是马后炮”,而是:
神经网络中很多关于“为什么有效”的解释,都是在实践成功之后逐步建立起来的;它们很有价值,但仍然需要持续接受实验的检验。
10.2 极简版本的训练代码
import torch
import torch.nn as nn
import torch.nn.functional as F
import tiktoken
# 模型配置
CFG = dict(
vocab_size=50257, # 词表大小:GPT-2 分词器一共有 50257 个 Token
context_length=128, # 一次最多看多少个 Token,也就是序列长度 N
emb_dim=256, # 每个 Token 的向量维度,也就是正文里的 d
n_heads=4, # 注意力头数:把 d 维拆成 4 份,各自算一套注意力,再拼回来
n_layers=4, # 堆叠多少个 Transformer 层
drop_rate=0.1, # Dropout 比例:训练时随机丢掉 10% 的数值,减轻过拟合
)
# ---------- 1. 模型 ----------
class Block(nn.Module):
"""一个 Transformer 层:注意力 + 前馈网络,两处都套着归一化和残差连接。"""
def __init__(self, cfg):
super().__init__()
d, self.h = cfg["emb_dim"], cfg["n_heads"]
# 归一化(8.5):控制每一层数值的尺度,让深层训练更稳定
self.norm1, self.norm2 = nn.LayerNorm(d), nn.LayerNorm(d)
# W_Q、W_K、W_V(8.2):三个矩阵合成一个线性层,一次算出 Q、K、V
self.qkv = nn.Linear(d, 3 * d, bias=False)
# 多个头的结果拼回来以后,再做一次线性变换
self.proj = nn.Linear(d, d)
# 前馈网络:先放大到 4d,经过非线性激活 GELU,再缩回 d
self.ff = nn.Sequential(nn.Linear(d, 4 * d), nn.GELU(), nn.Linear(4 * d, d))
self.drop = nn.Dropout(cfg["drop_rate"])
def attn(self, x):
b, t, d = x.shape # b:批次大小 B,t:序列长度 N,d:向量维度 D
# 算出 Q、K、V,并拆成 h 个头,每个的形状都是 (b, h, t, d/h)
q, k, v = self.qkv(x).view(b, t, 3, self.h, d // self.h).permute(2, 0, 3, 1, 4)
# 这一行就是 8.2 的 softmax(QKᵀ/√d_k)·V
# is_causal=True 是因果掩码:每个 Token 只能看自己和前面的 Token,不能偷看后文
out = F.scaled_dot_product_attention(q, k, v, is_causal=True)
# 把多个头拼回 (b, t, d)
return self.proj(out.transpose(1, 2).reshape(b, t, d))
def forward(self, x):
# 残差连接(8.5):x + F(x),给信息和梯度留一条直通路
x = x + self.drop(self.attn(self.norm1(x))) # 注意力:Token 之间互相交换信息
x = x + self.drop(self.ff(self.norm2(x))) # 前馈:每个 Token 各自再加工一遍
return x
class GPT(nn.Module):
def __init__(self, cfg):
super().__init__()
# Embedding(8.1):把 Token 编号映射成 d 维向量
self.tok_emb = nn.Embedding(cfg["vocab_size"], cfg["emb_dim"])
# 位置编码(8.1):注意力本身不区分顺序,所以每个位置也学一个向量
self.pos_emb = nn.Embedding(cfg["context_length"], cfg["emb_dim"])
self.drop = nn.Dropout(cfg["drop_rate"])
self.blocks = nn.Sequential(*[Block(cfg) for _ in range(cfg["n_layers"])])
self.norm = nn.LayerNorm(cfg["emb_dim"])
# W_vocab(8.2):把每个位置的向量变成词表上每个 Token 的分数(logits)
self.head = nn.Linear(cfg["emb_dim"], cfg["vocab_size"], bias=False)
def forward(self, idx):
# idx:(batch, seq),每个元素是一个 Token 编号
pos = torch.arange(idx.shape[1], device=idx.device)
x = self.drop(self.tok_emb(idx) + self.pos_emb(pos)) # Token 向量 + 位置向量
# 输出 (batch, seq, vocab):每个位置对“下一个 Token 是谁”的打分
return self.head(self.norm(self.blocks(x)))
# ---------- 2. 数据 ----------
def make_batches(ids, ctx, batch_size):
"""把一长串 Token 切成长度为 ctx 的片段,再组装成一个个 Batch(第五节)。
目标 y 是输入 x 整体右移一位:x 的第 i 个位置,要预测的就是 y 的第 i 个 Token。
比如 x = [小猫, 吃饱, 了],y = [吃饱, 了, ,]。一个片段就同时提供了 ctx 道“猜下一个词”的题。
"""
starts = range(0, len(ids) - ctx, ctx) # 每段起点间隔 ctx,片段之间不重叠
xs = [ids[i:i + ctx] for i in starts]
ys = [ids[i + 1:i + ctx + 1] for i in starts]
ds = torch.utils.data.TensorDataset(torch.tensor(xs), torch.tensor(ys))
# shuffle=True:每个 Epoch 打乱片段顺序;drop_last=True:凑不满一个 Batch 的尾巴丢掉
return torch.utils.data.DataLoader(ds, batch_size=batch_size, shuffle=True, drop_last=True)
# ---------- 3. 生成 ----------
@torch.no_grad() # 生成时只做前向计算,不需要梯度
def generate(model, idx, max_new_tokens, ctx):
"""贪心解码:每次取分数最高的下一个 Token,接到末尾,再接着预测。
真实的聊天模型通常会按概率采样,所以同一个问题问两次,答案可能不一样。
"""
for _ in range(max_new_tokens):
logits = model(idx[:, -ctx:])[:, -1, :] # 最多看最近 ctx 个 Token,只取最后一个位置的打分
idx = torch.cat([idx, logits.argmax(-1, keepdim=True)], dim=1)
return idx
# ---------- 4. 训练 ----------
def main():
torch.manual_seed(123) # 固定随机种子,方便复现
device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
tok = tiktoken.get_encoding("gpt2") # GPT-2 的分词器:文本 → Token 编号
text = open("the-verdict.txt", encoding="utf-8").read()
loader = make_batches(tok.encode(text), CFG["context_length"], batch_size=4)
model = GPT(CFG).to(device)
# 优化器:AdamW 是梯度下降的改进版(9.2),lr 就是学习率 η
opt = torch.optim.AdamW(model.parameters(), lr=4e-4, weight_decay=0.1)
for epoch in range(10): # 把全部数据过 10 遍,也就是 10 个 Epoch
model.train() # 训练模式:Dropout 生效
total = 0.0
for x, y in loader: # 每次取一个 Batch
x, y = x.to(device), y.to(device)
# ① 前向计算:得到每个位置对下一个 Token 的打分
logits = model(x)
# ② 计算损失:交叉熵,也就是 9.2 的 −log P(正确的 Token),对所有位置取平均
loss = F.cross_entropy(logits.flatten(0, 1), y.flatten())
# ③ 反向传播:先清掉上一步留下的梯度,再算出所有参数的梯度
opt.zero_grad()
loss.backward()
# ④ 更新参数:沿着让损失下降的方向走一步
opt.step()
total += loss.item()
# 每个 Epoch 结束,看一眼平均损失和生成效果
model.eval() # 评估模式:关掉 Dropout
prompt = torch.tensor([tok.encode("Every effort moves you")], device=device)
out = generate(model, prompt, 20, CFG["context_length"])
print(f"epoch {epoch + 1} | loss {total / len(loader):.3f} | {tok.decode(out[0].tolist())!r}")
torch.save(model.state_dict(), "model.pth") # 保存训练好的参数,也就是 θ*
if __name__ == "__main__":
main()
$```$