☰
负对数似然:从交叉熵到回归损失的本质解读
2026/10/8 3:15:15 网站建设 项目流程

写这篇文章的念头,源于我最近在带几个同学做深度学习项目时的感慨。很多人把CrossEntropyLoss挂在嘴边,但当我问“你知道你优化的到底是什么吗”,能准确说出“负对数似然”的却屈指可数。其实整个监督学习的半壁江山,归根结底都在做同一件事——最小化负对数似然(Negative Log-Likelihood, NLL)。它不仅是分类问题的损失函数,也是回归、生成模型、强化学习里一大票算法的理论基础。搞懂它,你再看各种loss函数会有一览众山小的感觉。

这篇文章我会从最直觉的层面切入,把公式拆开揉碎,讲清楚为什么我们要用“负对数似然”而不是其他东西,它在分类和回归里如何具体落地,以及工程实现中那些容易被忽略的数值坑。全程用实际案例和可运行的代码说话,确保新手能跟上,老手也能有所收获。

1. 从一个直觉问题开始:你训练的到底是什么?

先别急着看公式。我想先抛出一个问题:假设你训练一个模型做图片分类,输入一张猫的图片,模型输出一个向量[0.1, 0.7, 0.2],意思是“有10%的概率是狗,70%的概率是猫,20%的概率是鸟”。这时候你的损失函数会告诉模型:你把猫判成70%还不够,得让这个数字更接近100%。

但模型为什么非得这么做?换个角度看,我们其实是在做这样一件事:调整模型的参数,使得训练数据出现的概率尽可能大。这个朴素的逻辑就是“极大似然估计”的核心思想——你手里已经有一堆数据了,那就找一个模型参数,让这些数据被这个模型“生成”出来的概率达到最高。

这里有个非常关键的观点转变:我们通常以为模型是在“学习规律”,但数学上它只是在“拟合一个概率分布”。而负对数似然,就是把“让数据概率最大”这个目标翻译成“让损失最小”的桥梁。

那为什么是“负”的,又为什么取“对数”?如果你只有一点点数学基础,第一次看到-log P一定会困惑。别急,我一步一步拆给你看。

一个直观类比: 假设你在参加射击比赛,教练让你调整瞄准姿势。你打出一枪,中了8环。 “极大似然”的思路是:调整姿势,使得“打出8环这个结果”在所有可能的姿势下具有最大的出现概率。 损失函数就是告诉你——当前姿势下打出8环的概率有多低。

你现在只需要建立一个整体印象:负对数似然是一个数值,它越小,说明当前模型解释训练数据的能力越强。后面的章节都是在围绕这句话展开,为什么是对数、为什么加负号、以及它如何衍生出你每天在用的那些loss函数。

2. 似然与极大似然估计:负对数似然的前世今生

2.1 似然函数到底是什么

在统计学里,“概率”和“似然”是两个容易被混淆但本质不同的概念。简单说,概率是在参数已知时,预测结果出现的可能性;似然是在结果已知时,衡量不同参数下这个结果出现的可能性。

假设我们有一个简单的抛硬币实验,想估计硬币正面朝上的概率 (\theta)。我们抛了10次,得到7次正面、3次反面。那么:

  • 在 (\theta = 0.5) 的情况下,出现“7正3反”的概率是 (C_{10}^7 \times 0.5^7 \times 0.5^3 \approx 0.117);
  • 在 (\theta = 0.7) 的情况下,同样这个结果出现的概率是 (C_{10}^7 \times 0.7^7 \times 0.3^3 \approx 0.267)。

你看,(\theta) 取不同值,同一条观测结果的“出现概率”就不同。当我们把 (\theta) 看作变量时,关于 (\theta) 的这个函数就叫“似然函数”,记作 (L(\theta))。在这个例子里,(\theta = 0.7) 的似然比 (\theta = 0.5) 更高,所以7正3反这个结果更支持 “硬币偏向正面” 的假设。

推广到机器学习场景:假设我们有数据集 (\mathcal{D} = {x_1, x_2, ..., x_N})(这里的 (x_i) 可以理解为带标签的样本,即输入和输出的组合),模型参数是 (\theta)。模型给出的条件概率分布是 (p(x; \theta)),那整个数据集的似然函数就是所有样本概率的乘积:

[ L(\theta) = \prod_{i=1}^{N} p(x_i; \theta) ]

如果每个样本 (x_i) 对应输入 (\mathbf{x}_i) 和标签 (y_i),在监督学习场景下更常见的形式是:

[ L(\theta) = \prod_{i=1}^{N} p(y_i | \mathbf{x}_i; \theta) ]

我们的目标非常明确:找一个 (\theta^*),使得 (L(\theta)) 最大。这个目标用公式写就是:

[ \theta^* = \arg\max_{\theta} \prod_{i=1}^{N} p(y_i | \mathbf{x}_i; \theta) ]

这就是“极大似然估计”(Maximum Likelihood Estimation, MLE)的数学表达。

2.2 为什么中间要插一个“对数”

理论上,直接最大化似然函数 (L(\theta)) 就行了,为什么所有教材和工程实践都要先取对数?这里有三个非常实际的原因,每一个都在工程中真实发挥着作用。

第一个原因:防止数值下溢。深度学习中,单个概率值往往很小。比如一个分类模型输出0.1这样的概率,已经是10的负一次方量级了。假设有1000个样本,每个样本的概率大约是0.1,连乘结果是 (0.1^{1000} = 10^{-1000})。这个数字远远小于计算机能表示的最小浮点数(Python中float的最小值大约在 (10^{-308}) 左右),直接连乘会得到0,导致信息完全丢失。取对数后就变成了加法, (\sum \log(0.1) = -1000),安全地落在浮点数可表示范围内。

第二个原因:把乘法变加法,计算和求导方便。求导是模型优化的核心操作。乘法法则的导数是 ( (uv)' = u'v + uv'),如果连乘几百上千项,求导公式会变成极其冗长的嵌套结构。取对数后,连乘变成了连加,每一项的导数互相独立,直接相加即可,求导复杂度骤降。这就像你算 (2^3 \times 2^4) 时,把指数相加得到 (2^7),而不是真的去算 (8 \times 16)。

第三个原因:对数函数的单调性保证最优解不变。(\log) 函数在 ((0, +\infty)) 上是严格单调递增的,所以 (\log L(\theta)) 和 (L(\theta)) 在同一位置取得最大值。这意味着我们可以放心地优化对数似然,而不会改变原问题的解。

上面的数学处理之后,我们得到“对数似然函数”:

[ \ell(\theta) = \log L(\theta) = \sum_{i=1}^{N} \log p(y_i | \mathbf{x}_i; \theta) ]

在工程中你几乎见不到有人直接优化 (\ell(\theta)),因为优化算法习惯“最小化”,而不是“最大化”。这就引出了加负号的必要性。

2.3 为什么再加一个负号:从最大到最小的逆向思维

几乎所有深度学习框架的优化器(SGD、Adam等)都是沿着梯度下降的方向更新参数,也就是让损失函数越来越小。这是工程约定,也是优化理论里研究最深入的方向。因此,我们把“最大化对数似然”取个负号,变成“最小化负对数似然”:

[ \text{NLL}(\theta) = -\sum_{i=1}^{N} \log p(y_i | \mathbf{x}_i; \theta) ]

现在应该明白了:负对数似然不是凭空发明的新东西,它就是把“极大似然估计”这个统计目标,翻译成了优化器最喜欢的“最小化”语言。翻译前后的数学本质完全一致,但表述方式变了,工程上就顺了。

还有个很微妙的点值得一说:取负号之后,原来的乘积约束变成了求和约束,但每一项都是负数(因为概率在0到1之间,log后是负数)。所以NLL一定是非负数。如果某个样本的预测概率接近1,(\log) 值接近0,对损失的贡献就很小;如果预测概率接近0,(\log) 值趋近负无穷,对损失的贡献就极大。这个特性使得NLL会对“模型非常自信地预测错误标签”的情况施以重罚,梯度也很大,能快速把模型拉回来。

3. 从NLL到熟悉的损失函数:分类、回归一网打尽

3.1 分类任务:NLL就是那个你天天见的交叉熵

如果你用PyTorch写分类模型,一定写过或者见过这一行:

loss = F.cross_entropy(logits, targets)

很多初学者以为这里在做什么高深莫测的“交叉熵”操作,但本质上你就是在算负对数似然。我来推一遍,你就彻底通了。

假设一个 (K) 类分类任务。模型最后一层输出的是未经过归一化的“logits”向量 (z \in \mathbb{R}^K)。使用softmax把这些logits转成概率分布:

[ p_k = \frac{e^{z_k}}{\sum_{j=1}^{K} e^{z_j}} ]

对某一个样本 (x_i),如果它的真实类别是 (y_i)(一个整数索引),那模型预测这个样本属于正确类别的概率是 (p_{y_i})。对这个样本的负对数似然就是:

[ -\log p_{y_i} = -\log\left(\frac{e^{z_{y_i}}}{\sum_{j=1}^{K} e^{z_j}}\right) ]

把对数展开:

[ = -\left( z_{y_i} - \log\sum_{j=1}^{K} e^{z_j} \right) = -z_{y_i} + \log\sum_{j=1}^{K} e^{z_j} ]

再对所有样本求和取平均,就得到整个batch的NLL损失:

[ \text{NLL}{\text{batch}} = \frac{1}{N}\sum{i=1}^{N}\left(-z_{i, y_i} + \log\sum_{j=1}^{K} e^{z_{i,j}}\right) ]

这就是完全等价于PyTorchCrossEntropyLoss的公式。你看,整个过程没有任何神秘的“熵”出现,它就是负对数似然在分类问题上的具体展开。顺便说一句,F.nll_loss在PyTorch里接收的是已经log softmax过的输入,而CrossEntropyLoss是直接把logits扔进去(内部帮你做了一次log softmax)——本质上两者殊途同归。

3.2 回归任务:高斯NLL与MSE的关系

分类问题比较容易理解NLL,回归问题其实更精彩。大多数人都知道回归用MSE(均方误差),但很少想到MSE本身就是一个NLL特例。

假设我们要预测一个连续数值 (y),模型输出一个预测值 (\hat{y})。我们可以假设真实值服从以 (\hat{y}) 为中心的正态分布:

[ p(y | x; \theta) = \frac{1}{\sqrt{2\pi\sigma^2}} \exp\left(-\frac{(y - \hat{y})^2}{2\sigma^2}\right) ]

这里 (\sigma) 是标准差,表示模型对预测结果的不确定性。对这个分布取负对数似然:

[ -\log p(y | x) = \frac{(y - \hat{y})^2}{2\sigma^2} + \frac{1}{2}\log(2\pi\sigma^2) ]

如果把方差 (\sigma^2) 固定为一个常数(比如1),除了常数项外,损失就是 ((y - \hat{y})^2 / 2),这正是MSE的标量倍。换句话说,MSE是负对数似然在“方差固定”假设下的退化形式。

这解释了为什么MSE适合回归任务:它隐含的假设是误差服从等方差的高斯分布。当这个假设不成立时(比如有些样本天生难预测、误差方差更大),固定的MSE就没办法自适应地降低那些不确定样本的权重。

3.3 拓展:异方差回归与不确定性建模

NLL的优势在于它给了你一个统一的框架,可以自由地扩展。最有代表性的一个例子就是“异方差回归”(heteroscedastic regression),也就是让模型同时预测均值 (\mu) 和方差 (\sigma^2),这样模型在不同输入下可以表达不同的置信度。损失函数直接就是上面推导的高斯NLL:

[ \text{NLL}_{\text{Gaussian}} = \frac{(y - \mu)^2}{2\sigma^2} + \frac{1}{2}\log(2\pi\sigma^2) ]

这里的第二个项“(\frac{1}{2}\log(2\pi\sigma^2))”不是可有可无的摆设,它充当正则项:防止模型把方差调得特别大来“偷懒”降低损失。模型如果预测出了很大的方差,第一项变小了(因为除以更大的 (\sigma^2)),但第二项的正则惩罚会变大,平衡之下模型必须合理地预测不确定性。

我在实际项目中用这个方法做过多任务回归,效果非常惊艳。不仅预测效果更好,还白得了一个“不确定性估计”——当模型对某个样本把握不大时,预测方差会变大,下游系统就可以根据方差调整决策策略。这就是NLL框架比固定损失函数更灵活的地方:你换一个概率分布假设,就得到一种新loss。

4. 数值稳定性与工程实现:看似简单实则暗坑无数

4.1 可能遇到的精度下溢问题

前面推导了NLL在分类中的等价写法:

[ \text{NLL} = -z_{y_i} + \log\sum_{j=1}^{K} e^{z_j} ]

这里的 (\log\sum e^{z_j}) 就是大名鼎鼎的“LogSumExp”(LSE)。如果直接按照字面形式计算,会遇到一个严重的数值问题:当logits中存在很大的正数时,比如 (e^{1000}) 会直接溢出成inf。这在深度学习早期是真实发生过的坑,TensorFlow 1.x时代如果你手写softmax损失而不注意,偶尔能看到loss变成NaN。

解决办法是“平移不变性”技巧。因为:

[ \log\sum_{j=1}^{K} e^{z_j} = m + \log\sum_{j=1}^{K} e^{z_j - m} ]

其中 (m = \max_j z_j)。把最大值减掉后,指数部分最大也就是 (e^0 = 1),绝不会溢出。这个操作在数值上是稳定的,而且因为指数函数 (e^x) 的单调性,平移后的logsumexp值与原值在数学上是严格相等的(只是加了减了一个 (m))。现在的深度学习框架里logsumexp函数内部已经做了这个防护,但如果你手写损失函数,一定要自己实现这个技巧。

4.2 Softmax与NLL的联合实现

正确的工程实践是把softmax和对数损失合并成一个单独的计算单元。以PyTorch为例,F.cross_entropy接收原始logits并一次性完成log_softmax和nll_loss。为什么必须合并?因为分开做会有灾难性的精度损失。

如果你先算softmax(logits)得到一个概率分布,比如 (p_{y_i} = 0.00001),然后对它取log得 (-11.5129),这样流程没有大问题。但更极端的情况下,比如 (p_{y_i}) 因为浮点精度舍入变成了 (0),log(0)就等于-inf,梯度就全没了。合并成一个操作后,框架内部会直接计算 (z_{y_i} - \logsumexp(z)),绕开了“先算概率再取对数”这个容易丢失精度的中间路径。

这个“合并”的思路不仅适用于交叉熵,也适用于所有包含log和exp组合的运算。PyTorch里提供了F.log_softmax专门安全地完成 (z - \logsumexp(z)),如果你需要自己组合损失,请务必使用它而不是手动log(F.softmax(x, dim=-1))。

4.3 主流框架中的对应接口

不同框架对NLL的封装各不相同,但底层完全同构。我把关键的接口对照列出来,方便你在不同框架间迁移。

框架接口名称输入要求等价数学表达
PyTorchF.cross_entropy(logits, targets)未归一化的logits(-z_y + \log\sum e^z)
PyTorchF.nll_loss(log_probs, targets)已过log_softmax的数值(-\text{log_softmax}(z)_y)
TensorFlow/Kerastf.keras.losses.SparseCategoricalCrossentropy(from_logits=True)未归一化logits或概率同上
JAX/Flaxoptax.softmax_cross_entropy_with_integer_labels(logits, labels)未归一化logits同上

我个人的建议是:不到万不得已,永远使用框架内置的NLL实现。因为除了数值稳定,这些内置函数还处理了各种边界情况,比如负数索引、屏蔽词、梯度回传路径等。自己手写的NLL,除非测试集覆盖了所有极端情况,否则很容易在某个角落翻车。

4.4 手写一个NLL损失来加深理解

为了帮助你验证自己是否真的理解,我提供一个极简的纯NumPy实现,不依赖任何框架,也只适用于分类:

import numpy as np def nll_loss_from_logits(logits, targets): """ logits: shape (N, K),未归一化 targets: shape (N,),整数标签 """ # log_softmax 安全实现 shifted = logits - np.max(logits, axis=-1, keepdims=True) exp_shifted = np.exp(shifted) logsumexp = np.log(np.sum(exp_shifted, axis=-1, keepdims=True)) log_probs = shifted - logsumexp # 这就是 log_softmax 的结果 # 取正确类别的负对数似然 N = targets.shape[0] correct_log_probs = log_probs[np.arange(N), targets] return -np.mean(correct_log_probs) # 测试 logits = np.array([[2.0, 1.0, 0.1], [0.5, 2.2, 1.3]]) targets = np.array([0, 1]) loss = nll_loss_from_logits(logits, targets) print(loss) # 约 0.391

这段代码虽然简单,但你仔细对照公式看几遍,就能把“从logits到NLL”的每一步都在脑子里过一遍。理解了这个,你去看任何框架的交叉熵实现都会觉得熟悉。

5. 常见问题与排查技巧实录

5.1 问题一:为什么NLL在训练初期特别大?

很多新手第一次训练分类模型,看到初始loss在2.3左右就觉得不对劲。恰好在 CIFAR-10 这类10分类任务中,初始loss接近 (\log(10) \approx 2.3026) 是完全正常的。因为模型刚开始权重随机初始化时,对每个类别的预测概率都接近均匀分布 (1/10),所以NLL就是 (\log(10))。

如果初始loss远大于 (\log(K)),那是模型初始化有问题,大概率是你用了不合适的激活函数或初始化方式,导致初始输出被“推”向了极端自信的错误预测。如果初始loss远小于 (\log(K)),也要警觉,可能是数据泄露或者有些类别比别的类别出现得多。

5.2 问题二:NLL与样本不均衡怎么共处?

NLL天然对样本数量多的类别有偏向,因为它的总损失是每个样本的算术平均。当正负样本比例是1:9时,负类的梯度贡献是正类的9倍,模型自然会偏向预测负类。

处理方式有两种。第一种是为每个样本加一个权重系数,直接把NLL的单项改写为:

[ w_i \cdot (-\log p_{y_i}(x_i)) ]

在PyTorch里这个功能通过CrossEntropyLoss(weight=...)参数实现。第二种是采用类似“Focal Loss”的改造,它在NLL前面加一个调制因子 ((1 - p_t)^\gamma),让模型更加关注难分类样本。Focal Loss本质上没有脱离NLL框架,它只是在NLL每一项上乘了一个关于预测概率的动态权重。

5.3 问题三:NLL出现NaN/Inf怎么排查?

训练中出现NaN是一个经典问题,我把它最常见的三个原因和排查方式整理如下:

第一,logits溢出导致的前向传播问题。可以看logits的分布是否巨大,可能是网络里没有LayerNorm/BatchNorm,或者学习率太高。解决办法:在loss打印前手动检查是否有torch.isnan(logits).any()。

第二,梯度爆炸。NLL的梯度为 (p_k - y_k)(softmax输出减独热标签),这个值通常在[-1, 1]之间。如果网络层数太深或者RNN中梯度累积,越往底层梯度可能爆炸。解决:梯度裁剪clip_grad_norm_。

第三,标签错误。如果一个标签索引超出了类别总数,比如一共10类但标签是15,框架会直接报错或产生NaN。我在实际项目中就遇到过数据预处理时候标签从1开始计数,而框架默认为0开始,导致所有标签都偏移了1位。

5.4 问题四:什么时候NLL不适用?

NLL并非万能。当你的目标不是概率建模,或者概率假设严重偏离真实数据分布时,NLL的表现可能很差。

典型例子是图像生成任务中的“锐度”问题:用逐像素的高斯分布NLL作为损失训练生成模型,产出图像往往是模糊的,因为NLL鼓励模型输出“均值”,而图像的合理预测可能有多个模式,取均值会得到模糊的结果。这种情况下,对抗损失或扩散模型的损失设计会更合适。

还有在排序学习(Learning to Rank)中,NLL或者二元交叉熵并不直接优化排序指标(如NDCG),因为排序更关心相对顺序而不是绝对概率。所以有些排序模型会使用pairwise或listwise的损失,而不是逐样本的NLL。

5.5 一些小众但实用的调试技巧

最后分享几个我踩过坑后才养成的习惯。训练开始前,先用一个固定batch的数据跑一次前向和反向,看loss是否正常下降、梯度是否有限。完全不吃数据,只过模型,是最快的“冒烟测试”。

还有一个很实用的经验:如果你想验证自己的自定义NLL实现是否正确,可以用torch.autograd.gradcheck来检查梯度是否与数值差分一致,或者直接用框架自带的cross_entropy作为对照,跑同一个batch比较输出是否完全相同(注意设置随机种子)。

我在实际调模型时,特别喜欢观察NLL的逐类分解而不是只看总loss。方法很简单:把每个类别样本的NLL分别取平均,绘成柱状图。如果某个类别的NLL明显高于其他类别,那基本可以定位是这个类别的特征不够分离,或者是数据太少、样本噪声太大。这个观察在分类问题上帮我节省过大量排查时间。

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

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

立即咨询