训练一个三层网络做二分类损失曲线在前两百轮几乎是一条水平线把学习率翻十倍也只是让它抖两下——这种“学不动”的现场十有八九和损失函数的选择有关而交叉熵正是把这件事摆平的关键工具之一。它不是什么新奇的技巧而是一个在数学上能让你“错得越多、改得越快”的代价函数尤其当输出层用 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.25z 等于 2 时约 0.105z 等于 4 时约 0.0177z 等于 6 时约 0.00247z 等于 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 下直接等于 1log(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 或者 1log 炸了。如果你用的是带 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 会让分布更尖。这个技巧在知识蒸馏里是标准做法但在普通分类任务里乱加温度会让交叉熵的梯度尺度发生变化间接影响有效学习率。如果你要加记得同步调整学习率不然会出现“明明损失在降但准确率不动”的怪现象。最后说一个我在长期使用中形成的习惯每次换损失函数我都会保留一份用旧损失跑出来的基线结果包括最终指标、训练轮数、损失曲线。交叉熵通常收敛更快但这个“更快”到底快多少得有对照才能说清楚。没有基线的情况下你很容易把交叉熵带来的收益和其他改动的收益混在一起最后说不清到底是什么起了作用。做实验这件事控制变量比什么都重要。