资讯动态

交叉熵损失函数:分类任务梯度推导、手写实现与训练调参

发布时间:2026/10/26 4:48:01 来源:尧图企业网站定制
一个网络结构没动、数据没动、优化器超参也没动只是把损失函数从平方误差换成交叉熵训练前十几轮的损失曲线就从几乎不动的平线变成一条陡峭向下的斜坡——我第一次碰到这种对比手里只有一段跑了两小时、准确率还在 60% 附近打转的代码。那次之后我才真正理解神经网络学习方式的改进很多时候不是靠加层、加数据而是靠换一个更贴合任务本质的目标函数而交叉熵几乎是所有分类任务里最先该被换上的那一个。这篇内容只讲一件事交叉熵为什么能让网络学得更快、手写实现时有哪些数值陷阱、换上它之后训练配置要怎么跟着调整。它适合已经能跑通一个最基础的神经网络、但被“损失降不下去”“准确率卡住不动”折磨过的读者也适合完全没接触过信息论、只想把这几个公式真正用对的人。我会把推导、代码、手算数字、踩过的坑全部摊开你能直接对着复现。1. 同一个网络为什么换个损失函数就像换了台发动机1.1 从一次“学不动”的排查说起早些年我做手写数字分类的练手项目网络是两层全连接加 Sigmoid 输出损失用平方误差优化用最朴素的批量梯度下降。按理说这个任务简单到不该出问题但实际训练里出现了很诡异的现象损失确实在降可降得极慢前 20 轮几乎看不出变化更奇怪的是把学习率调大之后损失不是降得更快而是开始震荡甚至偶尔冲到一个大得离谱的值再慢慢爬回来。当时我的第一反应是数据有问题、初始化有问题、学习率有问题挨个排查了一遍都没找到症结。后来把每一步的梯度打印出来才看明白输出层的梯度量级小得可怜而且模型错得越离谱梯度反而越小。比如某个样本标签是 0网络输出 0.98错得非常自信可反向传回来的输出层梯度只有 0.019 这个量级。也就是说网络最需要被纠正的时候收到的纠正信号最弱。这不是调参能解决的问题是目标函数本身选错了。换成交叉熵之后同一个样本、同样的输出输出层拿到的梯度直接变成 0.98差了五十倍。训练曲线当天就变了个样。这件事给我的教训很直接在分类任务上输出层用 Sigmoid 或 Softmax、损失用平方误差是一个从数学上就不该出现的组合。它不会报错不会崩只是安静地把你的训练效率砍掉一大截让你误以为是模型容量不够或者数据不够。1.2 交叉熵在信息论里的本来面目要理解交叉熵得先接受一个视角神经网络的输出不是一个“数值”而是一个概率分布。二分类时输出层那个 0.83意思应该是“我认为这个样本属于正类的概率是 0.83”多分类时输出向量 [0.7, 0.2, 0.1]意思是“三类各自的概率是这些”。既然输出是分布标签也是分布one-hot 就是“百分之百属于某一类”那衡量两个分布的差异就不该用欧氏距离而该用信息论里的工具。信息论里衡量一个分布自身不确定性的量叫熵公式是 H(p) -Σ p_i log p_i。一个必然发生的事件熵为 0一个均匀分布比如十类各 0.1熵最大。而衡量“用分布 q 去编码真实分布 p 需要多少代价”的量就是交叉熵H(p, q) -Σ p_i log q_i关键性质是交叉熵永远不小于熵两者之差就是 KL 散度衡量的是 q 偏离 p 有多远。因为训练时真实分布 p 是固定的它的熵是个常数所以最小化交叉熵等价于最小化 KL 散度也就是让预测分布尽量靠近真实分布。这个解释比“交叉熵是个损失函数”要本质得多它告诉你为什么这个函数天然适合分类它衡量的就是分布之间的距离。落到 one-hot 标签上公式会大幅简化。因为 p 只有一个位置是 1其余全是 0求和里只剩一项H(p, q) -log q_y其中 y 是真实类别。也就是说交叉熵损失就是“正确类别的预测概率的负对数”。预测概率 1.0损失 0预测概率 0.5损失 0.693预测概率 0.01损失 4.6。损失随概率下降呈对数增长错得越狠惩罚越重这正是分类任务需要的形状。1.3 什么时候该换什么时候别乱换交叉熵不是万能钥匙得看清楚场景。分类任务、输出层是 Sigmoid 或 Softmax、标签是类别——这三个条件满足那就该用交叉熵几乎没有例外。多标签分类一个样本可以同时属于多个类用每个标签独立的二元交叉熵各输出节点互不干扰这也是标准做法。但如果是回归任务比如预测房价、预测角度、预测关键点坐标输出是连续数值而不是概率这时候硬套交叉熵是错的。有人把回归目标离散化成若干个 bin 再做分类这条路在某些场景比如预测图像里的坐标确实能用但那是因为他把问题重新定义成了分类而不是交叉熵能处理回归。还有一种常见误解既然交叉熵好那所有层是不是都该换不是。交叉熵只作用在输出层它解决的是“输出层梯度被激活函数导数稀释”的问题。中间层的激活函数选择ReLU、GELU 等是另一码事别混为一谈。提示判断标准很简单——你的模型输出能不能被解释成概率能就该考虑交叉熵不能就别硬上。1.4 换损失函数之前先想清楚你要优化的到底是什么我在带新人的时候发现一个普遍现象很多人换交叉熵是“因为教程都这么写”换完之后也说不清为什么变好了。这种状态下一旦遇到训练不收敛就完全失去了排查方向。所以在动手之前值得花十分钟想清楚一件事你优化的目标和你最终评测的指标是两回事。交叉熵优化的是“预测分布与真实分布的对齐程度”而你在业务上关心的可能是准确率、召回率、F1。两者高度相关但不完全一致。一个模型可以在交叉熵很低的同时准确率不涨因为它把原本 0.6 的预测推到了 0.9排序没变但置信度变了。理解了这一点你才不会在看到损失下降而准确率平台期时慌乱地去改学习率。2. 把梯度算一遍均方误差慢在哪里交叉熵快在哪里2.1 Sigmoid 加平方损失那个被乘了两次的导数先把符号定下来。二分类网络输出层是一个节点先算线性组合 z w·x b再过 Sigmoid 得到 a σ(z) 1/(1e^(-z))。Sigmoid 有个漂亮的性质σ(z) σ(z)(1-σ(z)) a(1-a)。如果用平方误差 L ½(a - y)²对 z 求导要用链式法则∂L/∂z (a - y) · ∂a/∂z (a - y) · a(1-a)这里的问题一目了然梯度里多乘了一个 a(1-a)。这个因子的取值范围是 (0, 0.25]在 a 接近 0 或接近 1 时趋近于 0。而 a 接近 0 或 1 恰恰意味着网络“非常自信”不管自信得对不对。于是出现了一个荒谬的结论网络越自信地犯错收到的纠正信号越微弱。这就是常说的梯度饱和问题的一个侧面。教科书上通常把它归因于激活函数但真正致命的地方在于它和平方损失的耦合——单独的 Sigmoid 并不会毁掉训练Sigmoid 配上平方损失才会。2.2 交叉熵的梯度推导只差一个 a 减 y换成二元交叉熵 L -[y log a (1-y) log(1-a)]求导过程稍微长一点但结果干净得惊人。先对 a 求导∂L/∂a -y/a (1-y)/(1-a)再乘上 ∂a/∂z a(1-a)∂L/∂z [ -y/a (1-y)/(1-a) ] · a(1-a) -y(1-a) (1-y)a a - y那些 a(1-a) 因子被完美约掉了。最后剩下的是最朴素的a - y也就是“预测减真实”。这个形式有多舒服预测准了梯度约等于 0预测偏了多少梯度就是多少误差的绝对幅度直接等于纠正的力度没有任何衰减因子。这个约分不是巧合是交叉熵与 Sigmoid以及 Softmax在数学上的一种“门当户对”。Sigmoid 的导数里带着 a(1-a)交叉熵的对数里带着 1/a 和 1/(1-a)两者相乘正好抵消。信息论上可以把它解释成“交叉熵是对数似然的负值”而 Sigmoid 输出就是伯努利分布的自然参数化两者是同一套概率模型的两面。2.3 用三组具体数字看差距光看公式没感觉直接算。表格里 L 那列取平方误差 ½(a-y)²梯度那列都是对 z 的偏导。场景真实标签 y网络输出 a平方损失 L平方损失梯度交叉熵损失 L交叉熵梯度梯度倍数自信地错00.980.4800.01923.9120.98约 51 倍拿不准00.500.1250.12500.6930.504 倍自信地对00.020.4800.0003920.0200.02约 51 倍这张表里最值得盯的是第一行和第三行。它们的梯度倍数都是约 51 倍看起来一样但含义完全不同。第一行是“网络错得离谱急需被拉回来”平方损失的梯度只有 0.0192几乎等于没信号第三行是“网络已经基本对了”平方损失梯度 0.000392小一点完全没问题。平方损失的梯度大小和“该不该纠正”完全脱钩交叉熵的梯度大小则精确等于“错的程度”。中间那行也很有意思当输出正好是 0.5、模型完全没主意的时候两者梯度只差 4 倍。这说明在训练的极早期输出接近 0.5 附近两种损失的表现差距没那么大——这也是为什么有些人会觉得“我用平方误差好像也能跑”因为前几轮确实能动。但随着输出被推离 0.5差距就迅速拉开了。2.4 多分类情况下结论完全一致多分类用 Softmax 输出a_i e^(z_i) / Σ_j e^(z_j)标签是 one-hot。交叉熵 L -Σ_i y_i log a_i因为 y 只有一项为 1实际上就是 -log a_kk 是真实类别。对 logits 求导的结果更干净∂L/∂z_i a_i - y_i每个类别一个分量形式上和二分类完全一致。预测概率高了就是正梯度要往下压低了就是负梯度要往上拉幅度就是偏差本身。作为对照如果输出层用 Softmax 而损失用平方误差求导时要穿过 Softmax 的雅可比矩阵结果里会带上 a_i(1-a_i) 这类因子梯度同样被压缩。数学细节不展开结论和多分类版本一样Softmax 就该配交叉熵这是唯一不需要额外解释的组合。手算一个例子确认一下。假设三个类别的 logits 是 [2.0, 0.5, -1.0]真实标签是第 0 类。先算 Softmaxe² ≈ 7.389e^0.5 ≈ 1.649e^-1 ≈ 0.368和是 9.406。于是 a ≈ [0.7856, 0.1753, 0.0391]。交叉熵损失 -ln(0.7856) ≈ 0.2414。梯度 a - y [-0.2144, 0.1753, 0.0391]三个分量加起来正好是 0符合 Softmax 输出的和恒为 1 这个约束。再看一个错得狠的例子logits 是 [-1.0, 0.5, 2.0]标签仍是第 0 类。这时 a ≈ [0.0391, 0.1753, 0.7856]损失 -ln(0.0391) ≈ 3.241梯度 [-0.9609, 0.1753, 0.7856]。注意第一个分量 -0.96几乎拉满而损失值也确实大。这就是我们想要的错得越狠拉力越大。3. 从零手写二分类与多分类交叉熵的完整实现3.1 二分类版本与数值校验先写最基础的二分类版本把公式落地import numpy as np def sigmoid(z): return 1.0 / (1.0 np.exp(-z)) def binary_cross_entropy(a, y, eps1e-12): a np.clip(a, eps, 1.0 - eps) return -np.mean(y * np.log(a) (1.0 - y) * np.log(1.0 - a))那行np.clip是必须的。log(0) 会得到负无穷反向传播时变成 NaN一个 batch 里只要有一个样本预测出极端值整个训练就废了。裁到一个极小的 eps 不算“作弊”它是工程上的常规做法代价只在于极端情况下损失被轻微截断对梯度几乎无影响。梯度实现对应的是 a - y但要注意 batch 平均def bce_backward(a, y): return (a - y) / a.shape[0]除以样本数是配合前面的np.mean。这一步很容易漏漏了之后梯度会随 batch size 成比例放大等效于把学习率乘了 batch size表现出来就是“换成大 batch 就炸”。写完一定要做数值梯度校验这是我认为最值得养成的习惯def numeric_grad(f, x, eps1e-6): g np.zeros_like(x, dtypefloat) it np.nditer(x, flags[multi_index]) for _ in it: idx it.multi_index old x[idx] x[idx] old eps fx1 f(x) x[idx] old - eps fx2 f(x) x[idx] old g[idx] (fx1 - fx2) / (2.0 * eps) return g把解析梯度和numeric_grad的结果对比相对误差小于 1e-6 才算通过。我有一次因为把 (1-y) 和 y 写反了损失值看起来还算正常靠这个校验才逮住。3.2 多分类版本Softmax 和 log 必须融合多分类的正确实现只有一个要点永远不要先算 Softmax 再算 log。Softmax 的输出可能因为浮点精度变成精确的 0 或者 1取 log 就是负无穷或者 0梯度直接爆。正确做法是把两者在数学上合并log a_i z_i - max(z) - log(Σ_j e^(z_j - max(z)))减去最大值这一步是数值技巧。因为 Softmax 对整体平移不变减去 max 不改变结果但能保证指数运算的输入不超过 0永远不会溢出。没有这一步logits 稍大一点比如超过 700就会在单精度下变成 inf。def log_softmax(logits, axis-1): m np.max(logits, axisaxis, keepdimsTrue) shifted logits - m return shifted - np.log(np.sum(np.exp(shifted), axisaxis, keepdimsTrue)) def softmax_xent(logits, labels): logp log_softmax(logits) n logits.shape[0] return -np.mean(logp[np.arange(n), labels])labels这里用整数索引而不是 one-hot纯粹为了省内存和少写两行代码数学上完全等价。梯度依然是那个漂亮的形式def softmax_xent_backward(logits, labels): n logits.shape[0] probs np.exp(log_softmax(logits)) grad probs.copy() grad[np.arange(n), labels] - 1.0 return grad / n注意这里np.exp(log_softmax(...))得到的其实就是 Softmax 输出但因为 log_softmax 已经做过减 max这一步不会溢出概率值也不会因为精度问题变成 0 之后取 log。3.3 反向传播的那几行为什么值得自己写一遍很多人习惯直接调框架里的损失层觉得没必要手写。我建议至少完整手写一次理由不是要你在生产里用而是只有写过一次你才知道框架在替你做什么。你要知道它内部做了减 max、做了 log 融合、做了 batch 平均这些认知会在你遇到 NaN、遇到梯度量级异常的时候救你。写完之后做个对照实验随机生成一批 logits 和标签把你的实现和框架里的结果对比损失值差异应该在 1e-6 量级再把梯度也比一遍差异同样应该很小。如果差得多大概率是三个地方之一减 max 没做、batch 平均没做、或者标签索引对错了维度。这里还有一个容易忽略的细节如果你的 logits 是二维的 (batch, classes)log_softmax 的 axis 必须是最后一维。有次我把 axis 设成了 0结果是在整个 batch 上做归一化损失值看起来还“能降”但准确率永远上不去。这种错误不会报错只会安静地浪费时间。3.4 框架里现成的封装以及最容易犯的误用主流框架都提供了现成的交叉熵封装名字通常带 CrossEntropyLoss 或 categorical_crossentropy。用它们能省事但有一个坑几乎人人踩过一次很多框架的交叉熵损失内部已经包含了 Softmax你不能再在网络末尾加一层 Softmax。也就是说如果你的模型最后一层输出的是未经激活的 logits直接送进交叉熵是正确用法如果你先过了一层 Softmax 变成概率再送进去等于做了两次归一化损失值会偏小、梯度会被压扁训练看起来“能跑”但效果差一截。判断方法很简单看损失值在训练开始时是不是明显低于 ln(类别数)。比如十分类随机初始化时理论损失应该在 2.3 附近如果你的损失一开始就是 1.5 甚至更低多半是重复归一化了。二元交叉熵的情况相反它通常要求输入已经过 Sigmoid或者框架提供了带 logits 的版本名字里常带 with_logits带 logits 的版本内部做了融合更安全。用之前确认清楚对应关系别想当然。4. 换上交叉熵之后训练配置要跟着调整的地方4.1 学习率与初始化别照搬原来的值换损失函数会改变梯度的量级这一点经常被忽略。前面算过同样的网络输出交叉熵给输出层的梯度可能是平方损失的几十倍那原来调好的学习率就偏大了。我的经验做法是换完之后先把学习率砍到原来的三分之一到五分之一观察前 20 轮的损失曲线。如果曲线是平滑下降的逐步加回去如果出现先降后翘、或者前几轮就震荡说明还是大了。千万别沿用之前的超参就直接跑长训练那会浪费大量时间。初始化同样值得重新看。输出层的偏置初始化如果全为 0Softmax 初始输出就是均匀分布损失正好是 ln(K)这是个很好的基准点——训练开始时的损失值应该紧贴这个基准偏离了就说明哪里不对。输出层权重初始化太大logits 会很大Softmax 迅速饱和交叉熵梯度虽然形式漂亮但对应类别的 logit 会被推得越来越极端表现出来就是损失前几轮降得飞快然后卡死。还有一个实用技巧如果发现训练初期 logits 的绝对值涨得很快比如超过 20可以在输出层前做一次轻微的归一化或者降低该层初始化的方差。这不是交叉熵的问题是前面的特征尺度没控制好。4.2 标签平滑与软标签纯 one-hot 标签配合交叉熵有一个副作用它要求正确类别的概率无限接近 1错误类别无限接近 0这需要 logits 的差值趋于无穷实际训练中表现为 logits 持续增长、模型过度自信。标签平滑就是针对这个问题的把目标分布从 [0,0,1,0] 改成 [ε/K, ε/K, 1-εε/K, ε/K]ε 通常取 0.1。对应的损失实现def smoothed_xent(logits, labels, eps0.1): n, k logits.shape logp log_softmax(logits) smooth np.full((n, k), eps / k) smooth[np.arange(n), labels] 1.0 - eps return -np.mean(np.sum(smooth * logp, axis1))多出来的那点计算量换来的是更平滑的 logits 分布和通常更好的泛化。我第一次在十分类任务上用标签平滑训练损失从接近 0 变成卡在 0.3 左右当时还以为模型退化了结果验证集准确率涨了一个多点。训练损失不再趋近于 0 是标签平滑的正常现象别当成 bug。软标签的用法类似如果你的标签来自人工标注的多方投票或者是蒸馏时教师模型的输出直接把这些概率分布当目标送进交叉熵比转成硬标签再训练效果好得多。这也是知识蒸馏的标准做法。要注意的是这种情况下输入必须是完整的分布而不是索引代码里用 ohe-hot 那一支。4.3 类别不平衡时交叉熵会偏袒多数类标准交叉熵对每个样本一视同仁如果 95% 的样本属于同一类模型把全部样本预测成多数类就能拿到很低的损失。这时候损失降得很好看但少数类的召回率惨不忍睹。加权交叉熵是最直接的应对给每个类别一个权重 w_c损失变成 -Σ w_c y_c log a_c权重通常取类别频率的倒数或开方后的倒数。开方是我的习惯因为完全按倒数加权在极端不平衡时会把少数类权重放大到几千倍梯度变得非常暴躁。超过几十倍的不平衡我更倾向换思路而不是硬调权重要么用重采样对少数类过采样、多数类欠采样要么换更适合的目标函数比如 focal loss 那类对易分样本降权的形式。加权交叉熵在我的经验里能处理 10:1 到 50:1 这个区间再极端就得配合采样一起上。注意加权之后别忘了同步调整学习率。少数类权重变大等效于该部分样本的梯度被放大原来合适的学习率可能就不合适了。4.4 该盯哪些指标别只盯损失值损失值和业务指标背离是常态尤其是在不平衡数据和标签平滑的情况下。我现在的习惯是同时盯四个量监控项观察目的异常信号训练损失优化是否在推进前 20 轮几乎不动或持续震荡验证损失是否过拟合训练降、验证升且间隔拉大准确率或 F1业务效果损失降但指标平多半是排序没变或标签问题输出层梯度范数梯度是否健康长期接近 0 或突然飙到极大第三项特别值得说。训练损失从 0.5 降到 0.2 但准确率一动不动通常意味着模型只是在把原本就对的那部分样本的置信度推高并没有修正错分的样本。这时候继续训练意义不大该去看数据分布或者错分样本的具体情况。5. 踩坑实录与排查速查表5.1 症状对照表下面这张表是我这些年攒下来的基本都是换交叉熵或调分类任务时遇到的按症状查比较快症状最可能的原因处理方式训练前几轮损失就是 NaN对 0 或 1 取 log或 logits 溢出用融合的 log_softmax检查减 max损失一直停在 ln(K) 附近不动输出层梯度被截断或学习率太小检查是否重复 Softmax检查学习率损失初期极低远低于 ln(K)标签泄漏或做了两次归一化检查模型末尾激活和损失封装损失降得飞快但准确率不涨标签平滑过头或严重不平衡降低 ε检查类别分布训练几十轮后损失突增学习率偏大logits 持续增长后爆炸降学习率加梯度裁剪验证损失远高于训练损失过拟合加正则、数据增强、早停某些类别的预测概率恒为 1.0Softmax 饱和logits 差值过大检查初始化方差加标签平滑5.2 几条教程里不写但很实用的经验第一用一个极小的数据集跑通全流程再上大的。拿 20 个样本、关掉正则、学习率调到能过拟合的程度训练几百轮。如果交叉熵实现正确损失应该能降到接近 0、准确率达到 100%。降不下去就说明实现有问题这时候排查成本极低。我每次重写损失层都会先跑这个“过拟合测试”比看公式快得多。第二把 logits 的统计量打印出来。训练过程中定期打印 logits 的均值和标准差、Softmax 概率的最大值比只看损失有用得多。典型健康状态是 logits 标准差在 2 到 8 之间波动如果标准差一路涨到 50 以上说明模型在过度自信早晚出事。第三混合精度训练时格外小心。半精度的动态范围小指数运算特别容易溢出。如果你在用混合精度务必用框架提供的融合版本交叉熵别自己手写 log(softmax)。我有一次自己实现单精度下完全正常切到混合精度后每隔几百步就出一个 NaN排查了大半天才定位到 exp 溢出。第四损失函数换了评估阈值也要重看。从平方损失换到交叉熵模型输出的概率分布形状会变原来在验证集上筛出来的 0.5 阈值可能不再最优。分类阈值应该在验证集上重新扫一遍按业务目标比如固定召回率下的精度选别沿用旧值。第五别迷信“交叉熵一定比平方损失好”这句话。前面的分析建立在“输出层是 Sigmoid 或 Softmax”这个前提上。如果你的网络输出层用了别的激活或者任务本身是回归这个结论就不成立。理解前提比记住结论重要。顺带说一个我踩过的隐性坑有一阵子我发现模型在训练集上的交叉熵降到了 0.02但推理时同一批数据的损失却是 0.15。查了很久发现是训练时开了数据增强推理时关了等于模型在两种不同分布上被评估。这类问题的教训是报损失的时候一定要说清楚是在哪个数据管线、哪个模式下算的否则数字之间根本没有可比性。最后分享一个我个人一直在用的小检查每次换损失函数后先只跑 5 轮同时记录损失、准确率、梯度范数三条曲线。三条曲线里有任何一条的形状不对比如梯度范数第 1 轮就该是最大值然后单调下降否则基本能确定实现或学习率有问题就别继续跑了。花五分钟做这个检查能省掉后面几小时的盲调。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑