做深度学习做到一定阶段,大家都会开始回补数学基础。标题里这几个词——Adam优化器、梯度下降、熵、过拟合、LN和BN——几乎每一次真实训练都会碰到,但很多人一开始只是死记公式,等模型训不动了才回头补课。这篇文章想把笔记重新整理一遍,把它们串成一条线:先搞懂梯度下降和Adam为什么这样更新,再从熵的角度看懂交叉熵损失,然后用偏差方差理解过拟合,最后把BatchNorm和LayerNorm的差异说清楚。适合刚开始写模型、或者在调参路上被几个概念卡住的同学,看的时候最好手边有一份能跑的代码,边看边改。
1. 从优化器到梯度下降:数学直觉先行
1.1 梯度下降到底在做什么
梯度下降是整个深度学习的发动机。别的都可以忘,这一条不能忘:我们要最小化一个损失函数L(θ),它把所有训练样本上的误差加起来,等于一个关于模型参数θ的高维曲面。训练的过程,就是在这个曲面上找一个足够低的点。
数学上,每一次参数更新写成:
θ_{t+1} = θ_t - η · ∇L(θ_t)
其中∇L(θ_t)是损失函数对参数θ的梯度向量,η是学习率。为什么要沿着负梯度方向走?因为梯度的方向是函数值上升最快的方向,那负梯度自然就是下降最快的方向。形象一点说,站在山坡上闭着眼睛往下走,每一步都踩在坡度最陡的方向,这就是梯度下降。
但这里有个很关键的数学直觉:梯度是一个局部量,只告诉你当前位置哪个方向最陡,不告诉你远处是不是有更低的地方。所以梯度下降本质上是一个贪婪的局部搜索策略,它一定会走到某个局部极小点,但没法保证那个点是全局最小。这也是为什么后来会有动量、随机重启、学习率调度这些机制——它们都是在跟“局部极小”和“病态曲率”作斗争。
举一个最简单的例子。假设损失函数是f(w) = w²,初始值w0 = 4,学习率η = 0.1。求导得到f'(w) = 2w,第一步梯度是8,更新后:
w1 = 4 - 0.1 × 8 = 3.2
第二步梯度是6.4,更新后:
w2 = 3.2 - 0.1 × 6.4 = 2.56
第三步变成2.048。可以看到参数在指数级地靠近0,这就是梯度下降在凸函数上的收敛过程。如果换成η = 1.1,第一步就会从4跳到-4.8,下一步又从-4.8跳到5.76,来回震荡甚至发散。所以学习率这个东西,往小调几乎永远是安全的,往大调则要万分小心。
1.2 学习率太小太慢,太大会炸:一个直观实验
很多人第一次调参时就困惑:为什么同一个模型,别人收敛得很好,我换了学习率就起飞?这不是玄学。把刚才的一维例子扩展一下就清楚了。
假设损失函数是f(w) = 0.5w²,真实最优解在w=0,我们分别用几组学习率跑 10 步:
| 学习率 | 第 1 步后 w | 第 5 步后 w | 第 10 步后 w | 表现 |
|---|---|---|---|---|
| 0.01 | 3.96 | 3.85 | 3.71 | 收敛太慢,10 步还没接近 0 |
| 0.1 | 3.6 | 2.36 | 1.13 | 稳步靠近 |
| 0.9 | 0.4 | 0.79 | 0.06 | 勉强收敛,中间震荡 |
| 1.1 | -4.4 | 5.37 | -12.3 | 发散 |
从小学习率到大学习率,模型会经历“慢吞吞”“正常收敛”“震荡收敛”“彻底发散”四个阶段。真正的问题在于:真实模型是几千维甚至上亿维的参数空间,不同方向上的曲率不一样,一个大学习率在一个方向上刚刚好,在另一个方向上已经炸了。这引出自适应学习率的原始动机,也就是Adam出现的基础。
1.3 Adam 如何把动量与自适应学习率合二为一
SGD最大的问题,是它对每一个参数都用同一个学习率,而且更新方向完全依赖当前梯度。如果当前梯度很小,参数更新就很慢;如果损失曲面像一个狭长的山谷,梯度会在谷壁方向来回震荡,沿谷底方向前进却非常慢。动量方法解决了一部分问题:它把历史梯度做了一个指数滑动平均,让更新方向不只是看当前梯度,还看之前几步的趋势。
m_t = β1 · m_{t-1} + (1 - β1) · g_t
这里g_t是当前梯度,β1通常取 0.9。如果把梯度方向想象成“风向”,动量相当于一个带惯性的小球,风向老变时它不会立刻跟着变,方向稳定时它会越滚越快。
Adam在动量基础上又叠加了自适应学习率。它对梯度的平方也做了滑动平均:
v_t = β2 · v_{t-1} + (1 - β2) · g_t²
这里的v_t近似等于梯度的二阶矩,也就是梯度大小的历史平均能量。更新时,用√v_t去除学习率:
θ_{t+1} = θ_t - η · m_t / (√v_t + ε)
这样做的效果是:某个方向梯度一直很大,v_t就大,实际步长变小;某个方向梯度一直很小,v_t就小,实际步长变大。所以Adam既能应对稀疏特征,又能应付崎岖的损失曲面。
Adam论文里还有一个重要细节:偏差修正。因为m_t和v_t初始化为 0,训练刚开始时估计值会明显偏小,所以要在前几步把估计值放大:
m̂_t = m_t / (1 - β1^t)
v̂_t = v_t / (1 - β2^t)
实践里,Adam的默认参数lr=0.001, β1=0.9, β2=0.999, ε=1e-8在绝大多数任务上都能直接开跑。但如果追求极致的泛化性能,不少实验发现SGD配合合适的学习率调度反而更好,Adam的优势是“好上手、不用经常调”,SGD的优势是“调好了更稳”。我的建议是:先把Adam跑通,再换SGD试一版做对比,不要让Adam成为偷懒的借口。
2. 熵:从信息论视角理解损失函数
2.1 信息量与熵
熵这个概念看起来抽象,其实一句话就能说清:熵是“不确定性”的平均度量。对于一个随机变量X,它取值x的概率是p(x),这一个事件带来的信息量定义为:
I(x) = -log p(x)
概率越小的事件,发生了之后信息量越大。比如“明天太阳照常升起”几乎是废话,信息量接近 0;而“明天有陨石”概率极小,信息量巨大。熵就是对所有可能事件的信息量按概率加权平均:
H(p) = -Σ p(x) log p(x)
如果一个事件只有两种可能,比如硬币正面概率p、反面概率1-p,熵写成-p log p - (1-p) log(1-p)。当p=0.5时,熵最大,因为此时最不确定;当p=0.99时,熵很小,因为结果几乎是确定的。
对比熵的过程能理解一个训练核心事实:高熵分布等于“均匀分布”,代表模型什么都没学会;低熵分布等于“很确定”,代表模型有把握。模型训练的目标,就是要让输出分布的熵降下来,而且是在正确的类别上降下来。
2.2 交叉熵为什么适合做损失函数
分类任务里,我们真实的标签分布p是一个one-hot分布,比如三分类中类别为 [0, 1, 0];模型预测出的分布是q,比如 [0.2, 0.5, 0.3]。交叉熵的定义是:
H(p, q) = -Σ p(x) log q(x)
因为p大部分位置是 0,只有正确类别那个位置是 1,所以交叉熵实际化简成:
H(p, q) = -log q(y_true)
也就是说,损失只跟模型给正确类别的预测概率有关。预测[0.2, 0.5, 0.3]时损失是-log 0.5 ≈ 0.693;预测[0.1, 0.8, 0.1]时损失是-log 0.8 ≈ 0.223。模型预测正确类别的概率越高,损失越低。
交叉熵配合softmax使用还有一个数值上的好处:softmax的输出永远不会严格等于0,但极端情况下可能非常接近0,这时-log q会变成很大的数。所以主流框架里的CrossEntropyLoss通常会把softmax和log合并成log_softmax再计算,避免直接把输出变成概率之后再去取对数,造成数值不稳定。
为什么不直接用均方误差MSE做分类?因为MSE对输出的梯度在softmax饱和区域会迅速变小。预测完全错了,MSE的梯度反而接近0,模型很难继续修正。交叉熵的梯度在错误时依然有稳定且非零的量级,训练也就更顺畅。这其实是上一节熵的数学结论在反向传播中的直接体现。
2.3 KL散度与模型训练的关系
KL散度衡量两个分布之间的差异程度,定义是:
D_KL(p || q) = Σ p(x) log (p(x) / q(x))
把它拆开:
D_KL(p || q) = Σ p(x) log p(x) - Σ p(x) log q(x) = -H(p) + H(p, q)
也就是说,KL散度等于交叉熵减去真实分布的熵。在训练分类模型时,真实数据分布p是固定的,所以H(p)是一个常数,最小化KL散度和最小化交叉熵完全等价。这也解释了为什么我们从不直接算KL散度,而是直接用交叉熵作为损失函数。
理解KL散度对后面接触生成模型、VAE、扩散模型都很有用。它一个经典反直觉特点是:D_KL(p||q)对q在p等于0的地方惩罚很小,对q在p很大的地方惩罚很大,所以最小化前向KL会把q拉向覆盖p的所有高概率区域。工程上遇到分布匹配问题,先分清自己算的是哪个方向,再决定用哪种损失。
3. 过拟合:模型复杂度与泛化能力的平衡
3.1 过拟合的表现与成因
过拟合是最常见的训练失败方式,特征是训练损失一路走低,验证损失却先降后升。可以把训练曲线想象成“背答案”:训练集上答得越来越好,新题上却越来差。本质原因是模型记住了数据里的特殊细节和噪声,而没有学到通用的规律。
造成过拟合的条件一般有三个:一是模型参数量远大于数据量,模型有足够的“容量”去记住每个样本;二是数据本身有噪声,模型把噪声当成了模式;三是训练时间过长,模型在某个特定迭代后开始“死记硬背”。我见过最典型的例子是一个公开的小型图像分类任务,用一个小而深的网络硬训,不加任何正则化,最后训练准确率接近100%,验证准确率只有72%。这时候看最后一个卷积层的输出分布,会发现大量神经元响应两极分化,明显是在做某种“查表”而不是提取通用特征。
3.2 从偏差方差角度看泛化误差
泛化误差可以分解成三部分:
泛化误差 = 偏差² + 方差 + 噪声
偏差衡量模型的平均预测与真实目标之间的差距。高偏差对应欠拟合,模型太简单,连训练集都拟合不好,典型的例子是用线性模型拟合非线性数据。方差衡量模型在不同训练集上表现的变化幅度。高方差对应过拟合,数据稍变一点,模型输出差异就很大。噪声是数据本身不可约的误差。
模型复杂度从小到大变化时,偏差会下降,方差会上升,泛化误差是一个U型曲线。所谓“过拟合”,就是复杂度已经越过U型曲线的最低点。理解这一点,就知道为什么正则化不应该被看作“玄学”,它本质上是在给模型容量加约束,把复杂度往U型曲线最低点方向拉。
一个直观实验只有极少的点也能触发过拟合。假设真实函数是y = sin(x),我们只采集5个点,然后用一个9阶多项式去拟合。多项式完全可以通过每一个数据点,但拟合出的曲线会在两点之间剧烈震荡,这个现象叫Runge现象。对深度网络来说,参数量足够大时,它就有类似的能力去“绕过”每一个训练样本。
3.3 工程里最管用的几招
应对过拟合,我从工程角度排个优先级:
第一是数据增强。图像任务做随机裁切、翻转、颜色扰动,文本任务做替换、回译,这几乎是无效免费的泛化神器。数据增强不只是多造数据,它还在告诉模型“这些变换不应该改变语义”,等于给模型注入了先验知识。
第二是正则化。L2正则也叫权重衰减,在损失函数里加λ||w||²,梯度更新时会额外把权重往0拉一点;L1正则加λ||w||,会诱导出稀疏解。Dropout则是在训练时随机把部分神经元置0,让模型不能依赖单个神经元,等于在多个子网络上做平均集成。
第三是早停。观察验证集准确率,连续多少个epoch不提升就保存当前最优模型并停止训练。很多人舍不得早停,总觉得“再训几轮可能就涨了”,但经验上验证集一旦开始稳定下滑,后面反弹的可能性很低。
| 方法 | 解决的问题 | 代价 |
|---|---|---|
| 数据增强 | 数据量不足、噪声模式 | 训练时间略增 |
| L2权重衰减 | 权重过大、模型敏感 | 需要调λ |
| Dropout | 神经元协变依赖 | 训练时间增加,推理需缩放 |
| 早停 | 训练过度 | 需要预留验证集 |
| 降低模型容量 | 结构过度复杂 | 可能欠拟合 |
4. BN与LN:归一化层的原理和应用
4.1 为什么要做归一化
先做一个类比。一个人体检,医生量身高用厘米、量体重用千克,两个数字一个170、一个70,量级不一样。如果不做任何处理就把两个数字直接相加,身高会完全主导结果。深度学习里也这样,网络某一层的输出如果分布范围差异过大,反向传播时算出的梯度在不同特征维度上会严重不均衡,小尺度的特征根本得不到有效更新。
更深层的问题是:随着训练进行,每一层的输入分布都在变。上一层参数一变,这一层的输入整体就被“换了一种分布”,这一层的参数刚适应了旧的分布,又要适应新的分布,每个层都在跟一个移动靶做斗争。早期研究者把这种现象称为内部协变量偏移。BatchNorm和LayerNorm就是为了缓解这类问题出现的。
归一化层做的事可以总结成两步:先把数据变换成均值为0、方差为1的标准分布,再进行一次“反标准化”,乘一个可学习缩放参数γ再加上可学习的平移参数β。为什么要有后面这一步?因为强制变成标准分布可能让模型损失表达能力,所以把原来的均值和方差变成可由网络学习的参数,让模型自己决定到底要不要完全标准化。
4.2 批归一化的计算流程与测试期“变身”
BatchNorm,简称BN,是在batch维度上做归一化。对于图像的卷积特征,通常是针对每一个通道,在该通道的所有batch样本上计算均值和方差。
训练阶段的公式:
μ_B = (1/m) Σ x_i
σ²_B = (1/m) Σ (x_i - μ_B)²
x̂_i = (x_i - μ_B) / √(σ²_B + ε)
y_i = γ x̂_i + β
这里的m是batch大小。注意,方差统计的是当前batch内部,所以batch越小,估计出的均值和方差越不准;batch越大,统计量越稳定。这也是为什么用BN时batch大小一般不能小于16,最好32以上。
测试阶段,BN会“变身”。测试时往往一次只喂一条或几条数据,不能直接用单个样本的统计量去标准化,而且我们希望模型对所有输入使用一套统一的统计量。所以训练过程中会额外维护一组滑动平均的均值和方差,叫做running mean和running var,测试时使用这套滑动统计量。
训练和测试使用不同统计量,这是BN最容易被忽视的坑。使用PyTorch这类框架时,模型训练完要记得调用model.eval()切换模式,否则BN层会继续用当前batch的统计量做推理,导致结果和训练时差别巨大。我在调模型时踩过这个坑,验证时一条一条数据慢慢测没什么问题,改成批量推理后忽然对不上,排查了一晚上才发现是忘了切换eval模式。
4.3 层归一化怎么改
LayerNorm,简称LN,最大区别是它不按batch维度做归一化,而是对某一个样本内部的所有特征做归一化。具体来说,对于一个特征向量x = [x1, x2, ..., xn],LN算出这个向量自身的均值和方差,然后标准化。
μ = (1/n) Σ x_i
σ² = (1/n) Σ (x_i - μ)²
x̂_i = (x_i - μ) / √(σ² + ε)
在Transformer结构中,每个token的embedding向量经过注意力层或前馈层之后,都会接一个LayerNorm。为什么要这么做?注意力层的输出维度很大,不同样本、不同token之间的统计特征差异很大,如果依赖batch统计量,会因为batch中样本数量不足而变得很不稳定。LN不依赖batch,每个样本独立计算,这在训练和推理之间的行为完全一致,不需要滑动统计量。
还有一层更实际的原因:NLP任务中句子长度不一样,padding会引入大量无效位置。BN在这些无效位置上计算统计量会污染整个归一化结果;LN直接对一个样本内有效位置的特征做归一化,天然避开了这个问题。
4.4 选BN还是LN,关键看这两个维度
工程选型时可以按两个维度判断。
第一个维度是特征的统计规律:如果训练数据在batch维度上有稳定的分布,比如图像分类中相同通道的亮度、边缘特征在全图范围内基本一致,BN更适用。如果单个样本内部特征之间的相对关系更重要,比如一个句子里不同位置的语义关联、一个时间序列前后时刻的依赖,LN更合适。
第二个维度是你的batch大小和训练稳定性:batch很大,BN统计量可靠,加速收敛效果明显;batch很小,BN统计量噪声大,越训越不稳定,LN因为不依赖batch反而更稳。在分布式训练、视频推理这种batch可能很小的场景,很多人开始用GroupNorm替代BN。
| 对比项 | BatchNorm(BN) | LayerNorm(LN) |
|---|---|---|
| 归一化维度 | 在batch维度统计每个特征通道 | 在单个样本内部统计全部特征 |
| 是否依赖batch大小 | 依赖,batch小则统计不稳定 | 不依赖,任意batch size都稳 |
| 训练/推理模式 | 训练用batch统计,推理用滑动平均 | 训练推理行为一致 |
| 典型应用 | 卷积网络、大batch图像任务 | Transformer、RNN、小batch任务 |
| 额外参数 | 每个通道一组γ、β | 每个特征向量一组γ、β |
在代码层面,BN和LN的调用逻辑差别很小,但计算细节完全不同:
import torch.nn as nn # 对二维图像特征做批量归一化,num_features = 通道数 bn = nn.BatchNorm2d(num_features=64) # 对最后一维特征做层归一化,normalized_shape = 特征维度 ln = nn.LayerNorm(normalized_shape=768)弄混了会出很隐蔽的问题。我见过有人把Transformer里的LN换成了BN,结果batch一缩小,训练完全不能收敛,一度以为公式写错了。做实验时如果发现小batch训练不稳定,优先怀疑BN,换成LN再对比一次。
5. 常见问题与排查技巧实录
5.1 损失一直不降或震荡
先检查学习率。Adam的默认学习率0.001在大多数任务上可以用,但如果是大batch训练、分布式训练,学习率通常要跟着batch大小做线性缩放,batch翻倍时学习率也可以翻倍。反过来,如果损失震荡得像心电图,先把学习率除以10,看有没有改善。
再检查输入数据的尺度。原始输入是0~255的图像还是0~1的浮点数,对收敛速度影响巨大。最好统一标准化成均值为0、方差为1的分布。内部特征和梯度不用太担心,有BN/LN层在会自动处理,但输入层第一层一般不会自动帮你做这件事。
最后看网络结构。激活函数如果用错了,比如在深层网络里用sigmoid,梯度很容易在反向传播中越传越小,前几层几乎学不到东西。排查时打印一下每一层的梯度和权重标准差,如果发现越往前梯度越小,基本就是梯度消失,该换激活函数或者加深归一化。
5.2 训练突然出现NaN
NaN是很多人最头疼的问题。先分类,再拆解:
第一类,log里面出现0。交叉熵损失里,如果某个样本在正确类别上的预测概率被算成了0,log(0)直接报负无穷。解决办法是用框架自带且内部做了数值稳定的交叉熵实现,或者在概率上加1e-8级别的epsilon。
第二类,除0。BN或LN的方差项是分母,如果方差计算出来是0,除出来就是NaN。这个通常发生在某一层输出恒为常数时,比如全0的padding区域。归一化公式里的ε就是干这个用的,但如果某一层输出了大范围相同的值,仍然会出现问题。
第三类,梯度爆炸。RNN和深层Transformer最容易出现,Adam对学习率做了自适应调整,但它并不会完全阻止梯度爆炸。常用的手段是梯度裁剪,把梯度的全局范数限制在一个范围内,比如5.0或1.0。加上梯度裁剪之后,至少能把训练保住,不至于一爆全没。
5.3 batch太小时BN的那个坑
batch size设为2或4时,用BN经常出现这种症状:训练集准确率忽高忽低,验证集准确率崩盘。根本原因是每个batch的均值方差波动太大,归一化后的特征已经被扭曲了。
解决办法有几种思路。如果不要求必须用BN,换成LN或者GroupNorm。如果模型结构已经定了,可以尝试增大batch size,或者用梯度累积让每个step的统计量更稳。还有一个很少被人注意的细节:做迁移学习时,把预训练模型冻结在eval模式,BN层继续把这些参数当作可训练,但没有对应的统计量,推理表现会非常奇怪。正确做法是冻结时把BN层也一起设置成eval或固定统计量,让BN保持和预训练时一致的分布状态。
最后说两句训练中积累的体会
我自己最开始是典型的“API调用型选手”,模型不加正则、优化器选Adam默认参数、归一化层随手加,出了问题就瞎调。直到有一次在某个跨平台系统的实验里,训练集指标几乎满分,线上指标却惨不忍睹,才意识到自己连过拟合的判断方法都没吃透。后来强制自己手写了一遍梯度下降、手推了Adam的更新公式,又从头实现了一个两层的MLP,把SGD、Adam、L2正则、BN和LN轮流组合起来跑实验,图表打出来做对比,很多东西一下就通了。
给同样在啃这些基础的同学一个具体建议:找一个小规模任务,不要用现成框架里封装好的接口,先把SGD和Adam各手写一遍,观察它们在同一个损失曲线上怎么走;再改模型容量和正则系数,把过拟合曲线自己训练出来一次;最后分别把BN和LN加到模型里,在batch size=2和batch size=32两种条件下对比收敛曲线。这组实验做完,标题里这几个关键词就不再是一堆孤立术语,而是你脑子里的一张联动地图了。