训练一个三层网络做二分类,损失曲线在前两百轮几乎是一条水平线,把学习率翻十倍也只是让它抖两下——这种“学不动”的现场,十有八九和损失函数的选择有关,而交叉熵正是把这件事摆平的关键工具之一。它不是什么新奇的技巧,而是一个在数学上能让你“错得越多、改得越快”的代价函数,尤其当输出层用 sigmoid 或 softmax 的时候,它几乎是默认选项。但默认选项不等于懂了就用对,我见过太多人把交叉熵当成一个可以随便替换的 API,结果在数值溢出、标签格式、类别不平衡这些地方反复翻车。
这一章要讲的东西,属于整个神经网络学习流程里最容易被低估的一环。大部分人学网络结构、学反向传播,注意力都放在层数、激活函数、优化器上,对损失函数的态度是“能跑就行”。可实际上,损失函数决定了误差信号以什么形态回传,它跟输出层激活函数的搭配关系,直接决定了梯度是充沛还是枯竭。这篇内容会把交叉熵从“为什么需要它”一直讲到“怎么把它写稳”,中间包含推导、代码、配对规则和一批实测踩出来的坑,适合已经能手写一层网络反向传播、但对自己训练曲线总是不太满意的读者。
1. 从一次“损失降不下去”的复现实验说起
1.1 一个刻意做小的复现现场
我印象最深的一次,是帮一位同学看他写的一个二分类小模型。数据是四百多条样本,两个特征,正负样本大概各占一半,网络结构是 2-4-1,隐藏层用 sigmoid,输出层也用 sigmoid,损失函数用的是均方误差,也就是把输出当成一个连续值去回归标签 0 和 1。他跟我说训练两百轮以后准确率卡在 0.52,权重几乎没怎么动。
为了排除干扰,我让他把数据换成一批完全线性可分的点,正类集中在右上,负类集中在左下,中间留了很大的间隔。按道理这种数据用逻辑回归都能秒分。结果训练曲线还是一样平,损失从 0.25 缓慢降到 0.24 就停住了。这一步很关键,因为它把“数据太复杂”“模型容量不够”这些嫌疑直接排除了,问题一定出在学习信号的传导路径上。
1.2 三个被依次排除的嫌疑
第一个嫌疑是初始化。他把权重初始化成均值 0、标准差 2 的正态分布,这个尺度对只有两个输入的网络来说确实偏大,会让隐藏层早早饱和。我们把它改成 Xavier 初始化,也就是按输入输出维度缩放的那个版本,曲线抖动了一下,但依然不收敛。
第二个嫌疑是学习率。从 0.1 试到 0.001,再试到 0.0001,都没有本质变化。学习率过大表现为震荡,过小表现为缓慢,而当时的现象是“稳如泰山”,两者都不像。
第三个嫌疑是数据归一化。两个特征的量纲差了一个数量级,确实会影响收敛速度,但做了标准化之后,只是让前二十轮的下降稍微快了一点,后面照样躺平。
三个嫌疑排完,剩下的就只有损失函数和输出层激活的搭配了。我们把输出层的均方误差换成二元交叉熵,其他一个参数都没动,重新跑一遍,第一轮损失就从 0.7 掉到 0.4,二十轮以内准确率到了 0.98。这个对比太干净了,干净到让人不得不去认真想:为什么同样是衡量误差,一个能学,一个学不动。
1.3 把变量收敛到梯度表达式上
复现做完之后,真正有价值的工作是把它解释清楚。我当时的做法是,直接把两种损失函数对权重的偏导数写出来,摆在一起看。均方误差的梯度里多了一个 sigmoid 的导数因子,而交叉熵的梯度里这个因子恰好被消掉了。这个“多一项”和“消一项”的差别,就是全部答案。
这里要强调一个方法论上的经验:当你遇到训练不动的现象,不要一上来就调超参数,先把损失对参数的梯度表达式手写一遍。很多时候答案就写在式子里,比盲试快得多。尤其是输出层附近的问题,梯度表达式能直接告诉你,误差信号在传到权重之前被衰减了多少倍。
2. 二次代价函数的梯度为什么会被 sigmoid 吃掉
2.1 用链式法则把梯度拆开
先把符号约定清楚。单个样本的输出记作 a,标签记作 y,加权输入记作 z,也就是 a 等于 sigmoid(z),z 等于权重乘输入再加偏置。二次代价函数写成 C 等于二分之一乘以 (a 减 y) 的平方,前面那个二分之一是为了求导时把平方的系数 2 消掉,纯属书写方便。
对权重求偏导,链式法则走三步:C 对 a 求导得到 (a 减 y),a 对 z 求导得到 sigmoid 的导数,z 对 w 求导得到输入 x。三个乘起来就是:
∂C/∂w = (a - y) · σ'(z) · x ∂C/∂b = (a - y) · σ'(z)这个式子里,(a 减 y) 是误差项,x 是输入,两者都没问题。问题全在 σ'(z) 上。sigmoid 的导数等于 σ(z) 乘以 (1 减 σ(z)),它的最大值在 z 等于 0 处取到,也就是 0.25。也就是说,无论误差项多大,这个因子都会把它至少压缩到四分之一。而当 z 的绝对值变大,σ'(z) 会迅速趋近于 0,误差信号就被压得几乎不存在了。
2.2 饱和区到底有多“平”
光说“趋近于 0”没有体感,我实测了几组数字,摆出来看更直观。当 z 等于 0 时,σ'(z) 是 0.25;z 等于 2 时,约 0.105;z 等于 4 时,约 0.0177;z 等于 6 时,约 0.00247;z 等于 8 时,已经掉到 0.000335。每增加 2 个单位,导数大概缩到原来的十分之一量级。
把这个衰减代进梯度公式,问题就非常明显了。假设某个样本标签是 1,但网络输出只有 0.01,误差项是 0.99,看起来很大。可这时候 z 大概在负 4.6 附近,σ'(z) 约等于 0.0099,两者相乘得到 0.0098。再假设另一个样本标签是 1,网络输出 0.99,误差项只有 0.01,但 z 在正 4.6,σ'(z) 同样是 0.0099,相乘得到 0.000099。
对比一下这两个数:一个错了 99%,一个只错了 1%,可前者拿到的梯度是后者的将近一百倍?不对,反过来了,后者拿到的梯度反而是前者的百分之一。这就是二次代价函数最反直觉的地方——错得越离谱,反而学得越慢。而对于一个刚开始训练、输出普遍接近 0.5 的网络来说,某些样本一旦被推到饱和区,就基本等于被判了缓刑。
2.3 一个容易被忽略的推论
从这个分析里能推出一个很实用的结论:二次代价函数配 sigmoid,在网络初始化不好或者学习率偏大的时候特别容易“死”。因为一次大梯度更新就可能把 z 推到很大的值,输出进入饱和区,此后这个样本几乎不再贡献有效的梯度。你在训练日志上看到的现象就是损失下降几轮之后突然卡住,然后一直不动。
我在实际项目里遇到这种情况,第一反应不是换损失函数,而是先看输出分布。如果发现大量输出挤在 0.001 以下或者 0.999 以上,那就说明已经进饱和区了。这时候换交叉熵确实能救回来,因为它把 σ'(z) 这个因子消掉了,但更根本的做法是把初始化尺度调小、学习率降下来,让网络压根别进那个区域。换损失函数是治标,控制进入饱和区的速度是治本,两者可以一起做。
3. 交叉熵的两条推导路径
3.1 从“消掉 σ'”这个目标反推
最直接的理解方式,是把它当成一个待求解的问题:我要找一个代价函数,使得它对 z 的导数恰好等于 (a 减 y),这样 σ'(z) 就被自然消掉了。写成式子就是 ∂C/∂z 等于 a 减 y。
对 a 积分回去,得到 C 对 a 的导数等于 (a 减 y) 除以 (a 乘以 (1 减 a))。这个式子可以做部分分式分解,拆成负的 y 除以 a,加上 (1 减 y) 除以 (1 减 a)。再对 a 积分,就得到:
C = -[ y·ln(a) + (1-y)·ln(1-a) ]这就是二元交叉熵。整个过程没有引入任何新的假设,纯粹是从“希望梯度干净”这个目标反推出来的。我觉得这条路径对工程师最友好,因为它直接回答了“为什么要长这样”这个问题——它就是这个特定形式,才能让梯度恰好等于误差项。
3.2 最大似然视角
第二条路径更正统一些。把网络输出 a 解释成“样本为正类的概率”,那么一个标签为 y 的样本,它的似然就是 a 的 y 次方乘以 (1 减 a) 的 (1 减 y) 次方。这个写法很巧妙,因为 y 只能取 0 或 1,取 0 的时候前一项变成 1,取 1 的时候后一项变成 1,一个式子覆盖两种情形。
对整个数据集取对数,把连乘变成连加,就得到对数似然。训练的目标是最大化似然,等价于最小化负对数似然,而负对数似然写开之后,正好就是所有样本交叉熵的和。这条路径的价值在于,它告诉你交叉熵不是拍脑袋设计的,它是伯努利分布假设下的标准做法。只要你把输出解释成概率,交叉熵就是唯一合理的选择。
3.3 信息论视角:它衡量的到底是什么
第三条路径从信息论来。交叉熵衡量的是“用预测分布 q 去编码真实分布 p 所需要的平均比特数”。把它拆开,等于真实分布的熵加上两个分布之间的 KL 散度。熵那一项只跟数据本身有关,训练过程中是常数,所以最小化交叉熵等价于最小化 KL 散度,也就是让预测分布尽量贴近真实分布。
这个视角有个很实际的用处:它能解释为什么交叉熵在预测“过于自信地犯错”时会给出非常大的惩罚。假设真实标签是 1,模型给出 0.99,交叉熵是 0.01;模型给出 0.01,交叉熵是 4.6。而这个 4.6 不是线性的,是随着预测概率趋近 0 而趋向无穷的。对数在接近 0 的地方变化极快,所以一旦模型把正确答案的概率压到很低,它就会被狠狠地罚一次。这种惩罚强度是二次代价给不出来的。
4. 把交叉熵写进代码:从朴素实现到数值稳定版本
4.1 朴素写法及其三个坑
按公式直译,二元交叉熵的代码就一行:
def bce_naive(y, a): return -(y * np.log(a) + (1 - y) * np.log(1 - a))这行代码在教科书里没问题,在生产环境里会出事。第一个坑是 log(0)。当 a 恰好等于 0 且 y 等于 1 时,log(a) 是负无穷,损失变成 inf,反向传播一轮之后所有权重变成 nan,整个训练报废。同理 a 等于 1 且 y 等于 0 时,log(1 减 a) 也会炸。
第二个坑是浮点精度。a 是一个 float32 的数,当它小到 1e-8 量级时,1 减 a 在 float32 下直接等于 1,log(1) 等于 0,误差信息丢失。这时候损失算出来是对的,但梯度是错的。
第三个坑是重复计算。sigmoid 在输出层算了一次,交叉熵里又对它的结果取对数,中间那次 sigmoid 的输出被量化过一次,精度已经损失了。更好的做法是把 sigmoid 和对数运算合并成一个算子,一步算完,不给中间结果落地的机会。
4.2 数值稳定版本怎么推
标准的做法是用 log-sum-exp 技巧,把 sigmoid 加交叉熵合并成一个从 z 直接算损失的函数。推导不复杂,把 a 等于 1 除以 (1 加 e 的负 z 次方) 代进交叉熵,化简之后得到:
loss = max(z, 0) - z·y + log(1 + exp(-|z|))这个式子的妙处在于,exp 的指数永远是负数或者零,所以 exp 的结果永远在 0 到 1 之间,不会溢出。max(z, 0) 那项处理的是 z 很大的情况,防止 exp(z) 爆炸。我用几个点验证过:z 等于 0、y 等于 1 时,损失是 log(2) 约 0.693,正确;z 等于 10、y 等于 1 时,损失接近 0,正确;z 等于负 10、y 等于 1 时,损失接近 10,正确。
工程上一般不会让你手写这个,主流框架里都有一个叫“带 logits 的交叉熵”的损失函数,输入直接是 z 而不是 a。我建议默认就用这个,别自己先算 sigmoid 再传给普通交叉熵,那是把稳定性白白扔掉。
4.3 多分类的 softmax 加交叉熵
多分类的情况稍微复杂一点,输出层用 softmax 把 K 个 logit 转成概率分布,损失用多元交叉熵,也就是负的 log(正确类别的概率)。朴素写法是先 softmax 再取 log,同样有溢出和精度问题。
正确的做法是合并成一个 log-softmax 加负对数似然的算子。log-softmax 的计算方式是每个 logit 减去该样本所有 logit 的最大值,再减去 log(sum(exp(减去最大值之后的 logit)))。减最大值这一步是关键,它保证了 exp 的输入永远小于等于 0,不会上溢。这个技巧看起来简单,但它是所有稳定实现的核心,值得单独记一下。
下面这个对比表是我在实际调试中总结的,列出几种常见写法和它们的风险等级:
| 写法 | 稳定性 | 常见问题 |
|---|---|---|
| 先 sigmoid 再取 log | 差 | a 为 0 或 1 时出 inf 或 nan |
| 先 softmax 再取 log | 差 | logit 差值大时上溢 |
| 加 clip 截断 | 中 | 梯度在截断处失真 |
| logits 直接进损失 | 好 | 需要框架支持合并算子 |
| 手写 log-sum-exp | 好 | 实现细节容易写错 |
4.4 梯度验证不能省
写完损失函数,我强烈建议做一次数值梯度检查。方法很简单,选一个参数,把它加上一个很小的量 ε,算一次损失;再减去 ε,算一次损失;两者之差除以 2ε,就得到数值梯度。把这个数和反向传播算出来的解析梯度比一下,相对误差在 1e-6 量级就算通过。
这一步在换损失函数的时候尤其重要,因为损失函数的梯度公式一旦写错,训练不会立刻报错,只会表现为“能学但学得不对劲”,这种问题最难查。我自己的习惯是,每换一次损失实现,就跑一遍梯度检查,花五分钟,省下后面几天的排查时间。
5. 损失函数与输出层的配对规则
5.1 一张必须记住的配对表
网络里损失函数和输出层激活是一对搭档,配错了不是不能跑,是跑得很难受。下面这张表是我按实际使用频率整理的:
| 任务类型 | 输出层激活 | 推荐损失 | 备注 |
|---|---|---|---|
| 二分类 | sigmoid | 二元交叉熵 | 用带 logits 的版本 |
| 多分类互斥 | softmax | 多元交叉熵 | 同上 |
| 多标签分类 | sigmoid(每个标签独立) | 每个标签的二元交叉熵求和 | 别用 softmax |
| 回归 | 恒等(无激活) | 均方误差 | 输出范围不受限时用 |
| 概率回归 | sigmoid | 交叉熵或 beta 似然 | 看目标分布形态 |
| 排序 | 无 | 成对或列表损失 | 不适用交叉熵 |
表里最容易被搞错的是多标签分类。多标签的意思是一张图可以同时属于“猫”和“室内”,每个标签是独立的伯努利变量,这时候输出层必须用 sigmoid,损失是每个标签的二元交叉熵相加。如果误用 softmax,它会强制所有标签的概率加起来等于 1,等于假设了标签互斥,模型会学到“有这个就不能有那个”的错误关系。这个错误我在实际项目里见过不止一次,而且很难从损失曲线上看出来,只能通过检查输出概率之和发现。
5.2 什么时候不该用交叉熵
交叉熵的前提是把输出解释成概率。如果你的任务是回归,输出是一个可以取任意实数的量,比如预测房价或者温度,那交叉熵根本不适用,因为它的定义域被限制在 0 到 1 之间,你没法把一个 30 万的房价塞进去。
即便是分类任务,也有例外。当标签本身带噪声、标注质量不高的时候,交叉熵对错误标签的惩罚是无穷大的,它会拼命去拟合那些本身就有问题的样本,导致过拟合。这种情况下可以考虑标签平滑,把硬标签 0 和 1 换成 0.05 和 0.95 这样的软标签,让模型不要那么绝对。标签平滑在分类任务上通常能带来一点泛化提升,代价是模型输出的置信度会整体偏低,做阈值决策的时候要相应调整。
5.3 类别不平衡下交叉熵的偏向
交叉熵是按样本平均的,这意味着多数类会主导梯度。假设正负样本比例是 99 比 1,模型只要把所有样本都预测成负类,交叉熵就已经很低了,因为那 99 个样本贡献的损失接近 0,只有 1 个正样本在拉梯度。这时候模型学到的其实是“全预测负类”这个退化解。
解决办法是给不同类别加权重,正类的损失乘一个大系数,负类乘一个小系数。权重的设置有个经验公式,正类权重等于负样本数除以总样本数,负类权重等于正样本数除以总样本数,这样两类对总损失的贡献大致拉平。另一个办法是用 focal loss 这类改版,它对已经分对的样本降低权重,把注意力集中到难样本上。这两种做法我都用过,加权重更简单直接,focal loss 在极端不平衡时效果更明显,但多了一个需要调的参数。
6. 实测中的几类失效场景与排查顺序
6.1 输出饱和导致的 nan 爆发
最常见的失效是训练到一半损失突然变成 nan。原因通常是某个样本的输出被推到了 0 或者 1,log 炸了。如果你用的是带 logits 的损失函数,这种情况基本不会发生,因为它在数学上就排除了 log(0)。但如果用的是朴素实现,或者自己在中间做了 sigmoid 再手动取 log,那这个风险一直存在。
排查方法是把损失计算那一步的中间变量打印出来,看有没有 inf 或者极端的值。我一般会在训练循环里加一个断言,检查损失是不是有限值,一旦出现 nan 就立刻中断并保存当前 batch 的数据,方便事后复盘。这个习惯帮我定位过好几次数据本身有问题的情况,比如某条样本的标签被标成了 2,而模型只输出 0 到 1。
6.2 学习率过大把网络推进饱和区
第二种失效是损失不炸但学不动,前面第 2 章讲过的那个现象。交叉熵虽然消掉了 σ'(z),但它消不掉“输出已经饱和”这个事实本身。如果学习率太大,一次更新就把 z 推到正负十几,输出变成 0.9999 或者 0.0001,此后这个样本的梯度确实还是 (a 减 y),但 a 已经贴边了,梯度也就贴边了,更新幅度依然很小。
诊断方法是统计每个 batch 输出的分布,看有多少比例落在 0.01 以下或者 0.99 以上。如果这个比例超过一半,说明网络大面积饱和。处理办法是降学习率、加梯度裁剪、或者换一个更温和的初始化。梯度裁剪在这里特别有用,它把单次更新的幅度限制住,防止一次大跳把网络推进死角。
6.3 一个按顺序执行的排查清单
把上面这些场景整理成一个可执行的排查顺序,遇到交叉熵相关的训练问题时按这个顺序走,比乱试快得多:
- 先确认损失函数和输出层是否配对,二分类 sigmoid 配二元交叉熵,多分类 softmax 配多元交叉熵,多标签用独立 sigmoid。
- 确认用的是带 logits 的版本,而不是手动先算激活再算损失。
- 检查标签的取值范围,二分类必须是 0 和 1,多分类必须是 0 到 K 减 1 的整数,且不能越界。
- 打印输出分布,看饱和比例,超过一半就先降学习率。
- 检查类别分布,严重不平衡就加权重或者换 focal loss。
- 跑一次梯度检查,确认反向传播的梯度数值正确。
- 最后才去怀疑网络结构和数据质量。
这个顺序的逻辑是,从最容易错、最便宜检查的地方开始,逐步往深处走。很多问题在第 1 步和第 2 步就能解决,根本不需要动模型结构。
我自己在这个流程上栽过的坑是第 3 步。有一次做多分类,标签是字符串形式,我在数据管道里做了一次映射,结果映射表写错了一行,某个类别被映射成了不存在的编号,损失函数把它当成背景类处理,训练看起来完全正常,只是那个类别的召回率永远是 0。这个问题查了整整两天,最后是靠打印标签分布才发现的。从那以后,我在每个项目开头都会加一段数据校验,把标签的唯一值、取值范围、各类计数全部打印一遍,三十秒的事,能省下大量的时间。
另一个值得分享的细节是关于 softmax 的温度。有些场景下你会看到别人在 softmax 之前除以一个温度系数,这本质上是调整输出分布的尖锐程度。温度大于 1 会让分布更平,小于 1 会让分布更尖。这个技巧在知识蒸馏里是标准做法,但在普通分类任务里乱加温度会让交叉熵的梯度尺度发生变化,间接影响有效学习率。如果你要加,记得同步调整学习率,不然会出现“明明损失在降但准确率不动”的怪现象。
最后说一个我在长期使用中形成的习惯:每次换损失函数,我都会保留一份用旧损失跑出来的基线结果,包括最终指标、训练轮数、损失曲线。交叉熵通常收敛更快,但这个“更快”到底快多少,得有对照才能说清楚。没有基线的情况下,你很容易把交叉熵带来的收益和其他改动的收益混在一起,最后说不清到底是什么起了作用。做实验这件事,控制变量比什么都重要。