2020年2月16日,我的深度学习笔记写到第八篇。上一篇从经验风险最小化聊到泛化误差,基本把“优化目标”讲清楚了;但目标归目标,真正让模型训练起来的是优化算法本身。这篇笔记把镜头拉到更新公式这一层,重点拆两块:基本优化算法里的梯度下降与动量,以及很多人一开始不太重视、却常常决定训练能否正常起步的参数初始化策略。
如果你正在复习深度学习基础、准备面试,或者发现自己的loss曲线莫名其妙不收敛,这篇应该对你有用。我会跳过复杂的证明,尽量把“为什么这么做”讲明白,再补一些我实际跑实验时踩过的坑。
1. 优化问题不能只盯着“梯度下降”四个字
1.1 参数空间里的一次次移动,才是训练的本质
训练一个神经网络,说穿了就是在参数空间里找一个点:让损失函数的值足够低,同时不把训练集上的表现原封不动搬到测试集上。反向传播给出的梯度,只是告诉我“当前位置往哪个方向走能最快降低损失”,真正决定怎么走的是优化算法。
所以我一直觉得,把优化理解成“沿着负梯度走”是不完整的。负梯度只提供了一个方向,步长、历史速度、噪声、起点位置共同决定了最终的落点。最近几年大家常用的自适应优化器也是在调整“步长怎么算”这件事,但基本算法反而是理解一切后续变体的地基。
1.2 基本算法和初始化为什么必须放在一起看
这一篇笔记把“初始化策略”和“梯度下降、动量”并列来讲,不是图省事。你在训练开始之前做的第一个动作,就是给参数赋一个初值;这个初值决定了网络一开始输出的数值范围,进而影响第一轮梯度的大小,影响动量缓冲区的初始状态。
如果初始化尺度不对,后面的优化算法再先进也很难救回来。尤其是深层网络,前向传播经过几十层非线性变换,方差要么爆炸要么消失,loss在第一次更新时就已经废掉了。所以,我在笔记里把这两件事绑在一起看:基本算法解决“怎么走”,初始化解决“从哪里开始”。
2. 梯度下降的三种形态:全量、随机、小批量
2.1 全量梯度下降:理论最干净,实战最奢侈
全量梯度下降的更新公式写成这样:
θ ← θ - η * ∇_θ L(θ)
这里的 L(θ) 是全体训练样本的平均损失。每一步都会把整个训练集过一遍反向传播,计算出来的梯度是整个数据集上损失函数的真实梯度。从数值上看,这个方向最稳,每走一步都严格往损失下降最快的方向走,收敛曲线也很好观察。
但它的问题非常现实:训练数据一旦有几万、几十万条,单次更新就要把整个数据集放进显存或者内存,时间成本和存储成本都扛不住。深度学习的训练集规模通常都很大,所以全量梯度下降在实际的深度网络训练里很少作为主力使用。它更适合小数据集、凸优化问题或者需要精确衡量梯度的调试场景。
2.2 随机梯度下降:单个样本的极端噪声
随机梯度下降把更新单位缩小到一个样本:
θ ← θ - η * ∇_θ ℓ(x_i, y_i, θ)
每次只拿一个样本来算梯度,更新频率非常高。用一个样本的梯度代替全体梯度,方向上有很大噪声,但也正是这种噪声让随机梯度下降偶尔能跳出损失曲面里的某些局部极小值或鞍点。
不过它的缺点也和优点一样明显:loss曲线会非常抖,像高倍镜下看心电图的杂波。如果学习率调不好,训练过程会一直在某个区域内来回横跳,甚至越跳越远。我现在很少用它直接训练网络,除非是在线学习场景:数据一条一条到,来了就更新。
2.3 小批量梯度下降:折中后的工业标准
实际训练里用得最多的是小批量梯度下降:每次从训练集里随机抽 m 个样本,算梯度的平均,然后更新一次。
θ ← θ - η * (1/m) Σ_{j=1}^{m} ∇_θ ℓ(x_j, y_j, θ)
这个小批量的大小通常取 32、64、128。为什么是这些数字?一方面是因为 GPU 的并行计算粒度喜欢这种对齐的数字,另一方面是批量太小梯度噪声大,批量太大计算成本和全量梯度接近。我自己的经验是:图像分类这类任务从 32 开始,批量越大学习率可以适当调大,批量越小学习率要谨慎一点。
小批量梯度下降让每一轮更新不偏随机、不偏全量,梯度带一点噪声但总体方向稳定,是后面动量、自适应学习率方法存在的前提。
2.4 三种形态对照和学习率的角色
| 形态 | 每次更新使用的样本数 | 梯度噪声 | 收敛稳定性 | 使用场景 |
|---|---|---|---|---|
| 全量梯度下降 | 全部 | 无 | 高 | 小数据、凸优化、调试 |
| 随机梯度下降 | 1 | 大 | 低 | 在线学习、极端内存限制 |
| 小批量梯度下降 | m(如32/64/128) | 适中 | 中高 | 深度网络训练主力 |
学习率 η 是所有梯度下降算法的灵魂。它相当于每走一步的步长:太小,loss 下降慢到让你怀疑人生;太大,loss 直接飞出“盆地”,表现为开局几个step就出现NaN,或者loss先降后疯狂震荡。
我在初学者阶段最喜欢做的一件事是:固定其他设置,只把学习率从 0.1 一路按 10 倍往下降,看loss曲线形态的变化。这个操作看起来笨,但对直观理解三种梯度下降形态很有帮助。
3. 动量:给梯度下降装上一个有记忆的速度
3.1 动量的物理直觉与指数滑动平均
标准梯度下降只依赖当前梯度,完全没有“记忆”。而损失曲面常常不是平滑的球面:有的方向坡面很陡、很快就能下降,有的方向坡面则比较平缓。如果只按当前梯度走,参数会在陡峭方向的谷壁两边来回震荡,白白浪费很多步。
动量方法给参数更新加了一个速度项,类比成一个球从山坡滚下来:速度会积累,不会因为局部坡度的细微变化立刻掉头。用公式表示,一般是先更新速度,再更新参数:
v ← μ * v - η * ∇_θ L(θ)
θ ← θ + v
这里的 μ 是动量系数,通常取 0.9。v 是历史梯度方向的指数滑动平均:当连续几个step的梯度方向一致时,v 的模会不断增大;当梯度方向在水平方向上反复横跳时,v 把正负梯度累加,互相抵消。所以动量能加速一致方向的更新,抑制振荡方向的更新。
3.2 标准动量的更新公式与工程实现
工程上实现标准动量时,我习惯按下面这段伪代码来理解:
# 标准动量更新(伪代码) v = v * momentum - learning_rate * grad param = param + v这个写法和数学公式唯一需要注意的地方是符号约定。有的框架把速度定义为 v = μ * v + learning_rate * grad,然后参数更新用 param = param - v。两种写法数学上等价,只是正负号放的位置不一样。我在复盘代码的时候经常被这一步绕晕,所以建议你自己维护一个统一符号:grad 统一表示损失对参数的梯度,速度项统一朝负梯度方向累积。
动量和学习率之间还有一个微妙关系:加入动量的等效步长,在方向一致时比普通梯度下降更大。也就是说,如果原来学习率 0.01 已经临界收敛,加了动量 0.9 之后可能就会过冲。所以调参时不要同时大改这两个值,一次只动一个。
3.3 提前量动量:先迈半步再看脚下的坡
标准动量是“先根据历史速度滑过去,到了地方再看路”。带提前量的动量则反过来:先用当前速度把位置向前探半步,再计算探到那个位置的梯度,用这个梯度来更新。
v ← μ * v - η * ∇_θ L(θ + μ * v)
θ ← θ + v
把这个公式里的 θ + μ*v 看成一个临时探路点。你可以理解为:一个跑步的人不是等到脚落地才判断方向,而是在空中就根据落点的坡面调整下一步。这个变体在很多情况下比标准动量更稳,收敛速度也稍有提升,尤其在回归或生成模型这类连续损失曲面上。
我见过不少资料把“带提前量”当作一个额外的可选技巧,实际上很多成熟优化器都默认集成过类似思路。你只需知道:它是标准动量的一个“看远处的修正版”,不是另一个完全独立的算法。
3.4 动量系数的调节经验
动量系数 μ 的常见值与效果如下:
| μ 取值 | 效果 |
|---|---|
| 0.5 | 更像轻微平滑的梯度下降,适合动量大改场景 |
| 0.9 | 最常见起点,兼顾速度与稳定 |
| 0.99 | 平滑更强,适合梯度噪声大的小批量场景,但容易“刹不住车” |
我的调参顺序一般是:先把 μ 固定为 0.9,用一个小实验把所有重点放在学习率上;等学习率定下来,再小幅调 μ,比如从 0.9 试到 0.95。80% 的模型在 0.9 附近就能得到满意结果,没有必要一上来就 0.99。
4. 参数初始化的底层逻辑:起跑点决定了能跑多远
4.1 对称性陷阱:零初始化为什么不行
如果网络所有参数都初始化为 0,那么同一层内的所有神经元在前向传播时会得到完全相同的输出,在反向传播时也会得到完全相同的梯度。无论训练多久,这些神经元都会一直保持着对称状态,无法学到不同的特征。这就像一群人长得一模一样、穿着一样、目标一样,让他们分工合作一点意义都没有。
常数初始化也一样,只是把“对称”从 0 变成了同一个常数。真正有意义的是随机初始化:让每个参数有独立的随机初始值,打破对称性,神经元才有机会分化出不同功能。
4.2 方差守恒:推导出合理的权重尺度
随机初始化不能乱来。如果初始化尺度太大,经过层层放大,激活值直接爆炸成NaN;如果尺度太小,激活值逐层缩水,到深层几乎等于零,梯度也跟着消失。
以最简单的线性层为例:
y = W * x
假设 W 和 x 独立,且 W 的每个元素均值为 0,x 的各分量均值为 0、方差为 1。输出的一维方差近似为:
Var(y) ≈ n_in * Var(W)
这里的 n_in 是输入维度,也就是 fan_in。如果我们希望输出方差和输入方差保持同一个量级,就要求:
Var(W) = 1 / n_in
这个思路是整个参数初始化策略的核心:初始化权重的方差,要根据网络层的输入输出维度来缩放。偏置项在做方差推导时通常会额外引入一个常数,所以实践上一般直接把偏置初始化为 0。
4.3 两套常见的缩放初始化策略与适用激活函数
针对不同的激活函数,实际常用的初始化尺度有两套。
第一套适合 sigmoid、tanh 这类在零点附近近似线性、两端饱和的激活函数。它同时考虑输入维度 fan_in 和输出维度 fan_out,把权重初始化为均匀分布 U(-limit, limit),极限取:
limit = sqrt(6 / (fan_in + fan_out))
这个来自均匀分布方差公式:如果 U(-a, a),那么方差是 a² / 3。代入 a² = 6 / (fan_in + fan_out),得到方差 2 / (fan_in + fan_out)。它既不像 1 / fan_in 那样只保前向传播,也不像 1 / fan_out 那样只保反向传播,而是中间均衡。
第二套是为现代深度网络里常见的 ReLU 类激活函数准备的,我会在下一小节详细讲。很多框架封装的“方差缩放初始化”底层就是按这两套公式来的,你只需要按激活函数选择模式。
4.4 ReLU激活函数存在的“方差减半”问题
ReLU 会把小于 0 的输入全部截断为 0。假设输入 x 的分布接近对称,均值为 0,那么经过 ReLU 之后,大约一半的信息被直接置零,输出的方差差不多减半。
为了让下一层的输入方差保持正常,初始化权重方差就需要翻倍:
Var(W) = 2 / fan_in
对应到均匀分布边界,就是:
limit = sqrt(6 / fan_in)
如果用正态分布初始化,可以用均值为 0、标准差为 sqrt(2 / fan_in) 的截断正态。
这里有件事很容易被忽略:如果你的前一层已经用了 ReLU 且方差控制得刚刚好,那么下一层的输入方差其实是上一层输出被截断后的方差,而不是上一层输入层的方差。我在理解这一层推导时,画了不少前向传播的示意图才彻底绕明白。简单记法是:凡是激活函数里存在“把负半轴清零”的操作,初始化方差就要适当放大。
4.5 初始化与优化算法的配合
一个好的初始化并不会保证训练一定快,但它保证训练在第一几步不会“原地去世”。相反,如果初始化特别差,哪怕后面用动量,用自适应学习率,也需要花很多额外step先把参数搬到合理区域。
另外,我发现一个容易踩的心态陷阱:觉得模型用上了自适应优化器,初始化就可以随便来。其实自适应优化器最多是让不同参数的更新幅度更均衡,它无法修复“信息从一开始就消失”的问题。信息一旦在前向传播里归零,反向传播的梯度也为零,自适应也拿不到信号。
从工程角度看,初始化还要注意随机种子:同样的网络结构,不同的随机种子可能带来最终的精度差异。让实验可复现是深度学习实验的基本素养,我会在每次训练前固定种子,并在关键实验里多换几个种子取中位数。
5. 我在实际训练中反复踩过的四个坑
5.1 动量太大,loss出现“过冲锯齿”
有一次做图像分类实验,网络不深,loss本来下降得挺好。我想加快收敛,就把动量从 0.9 调到 0.99,同时保持学习率不变。结果训练loss曲线出现一种非常典型的锯齿:连续下降两三个step,猛弹一下,然后再降。验证集指标也在一定区间来回波动,迟迟不上涨。
我排查了一圈,最后把动量调回 0.9,锯齿立刻消失。原因很简单:动量太大时,速度项的惯性会把参数“甩过”最优区域,类似开车速度太快冲过弯道。解决方式有两种:要么降低动量,把速度的刹车距离缩短;要么在前几个epoch做warmup,让动量从 0 慢慢爬到目标值。我更推荐后一种,尤其在大批量、大学习率场景里。
5.2 换激活函数后没换初始化尺度
有一段时间我把网络的 tanh 激活函数批量替换成 ReLU,但初始化策略沿用了之前的均匀缩放,没有切换成适配 ReLU 的方差版本。结果训练loss下降变得很慢,前几个epoch几乎没有动静。
用脚本打印了每一层输出的标准差,发现从第二层开始,激活值标准差的数值就在逐层下降。换成适配 ReLU 的初始化后,深层激活值的尺度恢复了,loss在第一个epoch就开始稳步下降。自此以后,我每次换激活函数,都会顺手检查初始化策略是否需要同步更新。
5.3 深层网络前向方差悄悄消失
在一次搭建超过二十层的纯卷积结构的模拟项目里,我用的是 1 / fan_in 的普通缩放初始化,搭配 ReLU。结果前向传播到第十层左右,特征图的标准差已经小到接近浮点精度极限,反向传播在更早的地方就已经没有了有效梯度。
这个问题靠看loss曲线很难第一时间发现,因为loss在一开始就保持一个常数,你甚至会误以为是学习率太小。最直观的诊断方式就是打印各层前向输出的均值、标准差。后来我们把初始化改为 2 / fan_in 的ReLU适配版本,同结构网络在相同训练步数内,验证集指标提升了非常明显。这个例子让我相信:初始化不是一个可以随便选默认值的细节。
5.4 一个快速诊断训练状态的模板
我现在每搭一个新网络,都会在正式训练前先跑一个五六行伪代码的“体检”:
# 伪代码:逐层打印激活分布 model.forward(input_batch, return_hidden=True) for layer_name, hidden_output in hidden_units.items(): print(layer_name, hidden_output.mean(), hidden_output.std())我会关注两个点:一是标准差不随网络层数急剧缩小或爆炸,二是没有任何一层的激活值出现全零或全相同。形态正常之后再接入优化器,用一小批数据跑两三个step,观察loss是否肉眼可见地下降。如果loss在第一步就出现NaN,优先检查学习率和初始化尺度;如果loss一点不动,优先检查激活值方差和梯度是否消失。
这套流程听起来特别简单,但真的能在我手里省下好几个小时的无效训练。优化算法和初始化策略说到底都是为了同一件事:让梯度信号稳定地在网络里流动。只要信号还在流动,训练就只是时间问题;信号一旦断掉,再好的优化器也没有用武之地。
其实这一篇笔记写到这里,我最大的体会倒不是哪个公式更漂亮,而是“先诊断,再调参”。我遇到的大部分训练问题,都不是单一原因造成的,而是学习率、动量、初始化尺度、批量大小好几个因素纠缠在一起。能把它们拆开看,分步试,才算是真正理解了优化这一章。