☰
交叉熵损失函数:分类任务梯度推导、手写实现与训练调参
2026/10/26 4:47:57 网站建设 项目流程

一个网络结构没动、数据没动、优化器超参也没动,只是把损失函数从平方误差换成交叉熵,训练前十几轮的损失曲线就从几乎不动的平线变成一条陡峭向下的斜坡——我第一次碰到这种对比,手里只有一段跑了两小时、准确率还在 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/(1+e^(-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_k,k 是真实类别。

对 logits 求导的结果更干净:∂L/∂z_i = a_i - y_i,每个类别一个分量,形式上和二分类完全一致。预测概率高了就是正梯度(要往下压),低了就是负梯度(要往上拉),幅度就是偏差本身。

作为对照,如果输出层用 Softmax 而损失用平方误差,求导时要穿过 Softmax 的雅可比矩阵,结果里会带上 a_i(1-a_i) 这类因子,梯度同样被压缩。数学细节不展开,结论和多分类版本一样:Softmax 就该配交叉熵,这是唯一不需要额外解释的组合。

手算一个例子确认一下。假设三个类别的 logits 是 [2.0, 0.5, -1.0],真实标签是第 0 类。先算 Softmax:e² ≈ 7.389,e^0.5 ≈ 1.649,e^-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, eps=1e-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, eps=1e-6): g = np.zeros_like(x, dtype=float) 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, axis=axis, keepdims=True) shifted = logits - m return shifted - np.log(np.sum(np.exp(shifted), axis=axis, keepdims=True)) 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 轮的损失曲线。如果曲线是平滑下降的,逐步加回去;如果出现先降后翘、或者前几轮就震荡,说明还是大了。千万别沿用之前的超参就直接跑长训练,那会浪费大量时间。

初始化同样值得重新看。输出层的偏置初始化如果全为 0,Softmax 初始输出就是均匀分布,损失正好是 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, eps=0.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, axis=1))

多出来的那点计算量换来的是更平滑的 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 轮就该是最大值然后单调下降,否则基本能确定实现或学习率有问题),就别继续跑了。花五分钟做这个检查,能省掉后面几小时的盲调。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询