资讯动态

深入解析损失函数:从MNIST分类任务彻底搞懂交叉熵

发布时间:2026/10/16 8:00:11 来源:尧图企业网站定制
1. 从“模型为什么学不会”说起损失函数在MNIST任务里的真实角色很多人跑MNIST手写数字识别代码一抄、模型一搭、训练一开看到准确率能到95%以上就觉得万事大吉了。但如果你问他一句你的模型到底在“学”什么它凭什么知道自己错了错得有多离谱大部分人答不上来。这个问题的答案就藏在损失函数里。我见过不少初学者模型结构能背得滚瓜烂熟卷积层、池化层、全连接层说得头头是道但一提到损失函数就只会说“用交叉熵就行了”。至于为什么分类任务用交叉熵而不是均方误差交叉熵在代码里到底算了什么softmax和交叉熵为什么总是成对出现换一个损失函数会带来什么后果——这些真正决定模型能不能训起来、训得好不好的关键问题反而被忽略了。这篇内容就是要把损失函数这件事彻底讲透。我会围绕MNIST手写数字识别这个具体任务从“模型输出是什么”开始一步步推导到“损失值怎么算出来”再落到代码层面看每一行到底在做什么。不管你是刚接触深度学习的新手还是已经跑过几个项目但对底层细节模糊的开发者都能从中拿到可以直接用的东西。核心关键词就三个损失函数、交叉熵、MNIST分类。搞懂这三个词之间的关系你后面遇到任何分类任务都不会慌。2. 模型输出到损失值一条完整的计算链路拆解2.1 神经网络到底输出了什么先别急着看损失函数我们得先搞清楚模型的输出长什么样。MNIST是一个10分类任务识别0到9这十个数字。假设我们搭了一个简单的全连接网络最后一层是10个神经元那么对于一个输入图片网络最后会输出10个实数。这10个实数可能是这样的[2.3, -1.5, 0.8, 4.1, -0.2, 1.1, 0.3, -0.7, 1.9, 0.5]。这些数字叫logits直译过来叫“逻辑值”你可以把它理解为模型对每个类别的“原始打分”。分数越高模型越倾向于认为这张图属于那个类别。但问题来了这些分数有正有负大小范围也不固定你没法直接拿它当概率用。比如2.3这个分数它代表多少概率说不清楚。所以我们需要一个东西把这些原始分数变成概率分布——这就是softmax函数干的事。softmax的公式很简单对每个分数取指数然后除以所有分数指数之和。用刚才那组数举例取指数之后大概变成[9.97, 0.22, 2.23, 60.34, 0.82, 3.00, 1.35, 0.50, 6.69, 1.65]加起来约86.77然后每个除以这个和得到[0.115, 0.003, 0.026, 0.695, 0.009, 0.035, 0.016, 0.006, 0.077, 0.019]。你看现在这10个数加起来正好等于1而且每个都在0到1之间这就是一个合法的概率分布了。注意softmax有一个很重要的特性——它不改变各个分数之间的大小关系。原来分数最高的那个softmax之后概率也最高。所以模型预测的类别就是softmax之后概率最大的那个位置对应的数字。2.2 从概率分布到损失值我们需要一个“打分标准”现在模型给出了一个概率分布比如它说“这张图有69.5%的概率是数字3”。而我们知道这张图的真实标签是3。那这个预测算好还是不好如果模型说“有99%的概率是3”那显然比69.5%要好。如果模型说“只有10%的概率是3”那就很差。我们需要一个数值来衡量“模型预测的概率分布”和“真实标签”之间的差距。这个数值就是损失值。损失值越小说明模型预测得越准损失值越大说明模型错得越离谱。而计算这个损失值的函数就是损失函数。这里有一个非常关键的认知损失函数不是给人类看的是给优化器看的。优化器比如SGD、Adam会根据损失值计算梯度然后调整网络参数让损失值变小。所以损失函数必须是一个可导的函数而且它的梯度要能正确反映“参数往哪个方向调损失会下降”。2.3 交叉熵损失的计算过程一个具体例子对于分类任务最常用的损失函数是交叉熵损失Cross-Entropy Loss。它的公式看起来有点吓人但拆开看其实很直观。假设真实标签是数字3那么真实标签的one-hot编码就是[0, 0, 0, 1, 0, 0, 0, 0, 0, 0]也就是说第4个位置索引从0开始是1其他都是0。模型预测的概率分布是[0.115, 0.003, 0.026, 0.695, 0.009, 0.035, 0.016, 0.006, 0.077, 0.019]。交叉熵的计算方式是把真实标签为1的那个位置对应的预测概率取出来然后取负对数。用公式写就是Loss -log(预测概率)。在这个例子里真实标签3对应的预测概率是0.695所以Loss -log(0.695) ≈ 0.364。如果模型预测得更准比如预测概率是0.99那么Loss -log(0.99) ≈ 0.01损失非常小。如果模型预测得很差比如只给了0.1的概率那么Loss -log(0.1) ≈ 2.303损失就很大。这里有一个容易混淆的点交叉熵的公式里其实还有一个求和符号但因为one-hot编码里只有真实标签那个位置是1其他都是0所以求和之后只剩下真实标签那一项。这就是为什么实际计算时只需要取一个概率值取负对数就行了。2.4 为什么不是均方误差一个反直觉的对比很多人会想既然都是衡量差距为什么不用均方误差MSEMSE不是更直观吗把预测概率和真实标签的one-hot编码逐元素相减、平方、求和不也能得到一个损失值吗理论上可以但实际效果差很多。原因在于梯度特性。交叉熵损失配合softmax使用时梯度形式非常简洁预测概率减去真实标签。也就是说如果真实标签是3预测概率是0.695那么梯度就是0.695 - 1 -0.305对应位置其他位置就是预测概率本身。这个梯度直接反映了“预测和真实之间的差距”而且差距越大梯度越大参数更新越快。而MSE配合softmax时梯度里会多出一个softmax的导数项这个导数项在预测概率接近0或1时会变得非常小导致梯度消失。也就是说当模型错得很离谱的时候MSE反而给不出足够大的梯度来纠正错误。这就是为什么分类任务几乎不用MSE而用交叉熵。我用一个表格来对比这两种损失函数在MNIST任务上的表现差异对比维度交叉熵损失均方误差梯度形式预测概率 - 真实标签简洁直接包含softmax导数形式复杂梯度消失风险低错得越离谱梯度越大高预测极端时梯度趋近于0收敛速度快通常几个epoch就能到90%慢可能需要更多epoch适用场景分类任务首选回归任务更合适MNIST实测准确率容易达到98%以上通常低几个百分点这个对比不是纸上谈兵我自己在早期学习时就踩过这个坑。当时觉得MSE简单就拿来跑MNIST结果训练了10个epoch准确率还在90%左右晃悠换成交叉熵之后3个epoch就到97%了。后来才明白不是模型不行是损失函数选错了。3. PyTorch里的交叉熵代码逐行拆解与常见误区3.1 nn.CrossEntropyLoss到底做了什么在PyTorch里最常用的分类损失函数是nn.CrossEntropyLoss。但很多人不知道的是这个函数其实把softmax和交叉熵合并在一起了。也就是说你传给它的应该是模型的原始输出logits而不是经过softmax之后的概率。这一点非常关键我见过太多人在这里犯错。他们先手动做了softmax然后把概率值传给nn.CrossEntropyLoss结果训练效果很差还找不到原因。正确的做法是模型最后一层直接输出logits不要加softmax然后把logits直接传给损失函数。import torch import torch.nn as nn # 假设模型输出的是logits形状为(batch_size, 10) logits torch.randn(4, 10) # 4个样本10个类别 # 真实标签形状为(batch_size,)每个值在0到9之间 labels torch.tensor([3, 7, 1, 9]) # 正确用法直接传logits criterion nn.CrossEntropyLoss() loss criterion(logits, labels) print(loss.item())如果你手动做了softmax再传进去相当于做了两次softmax概率分布会被“压平”模型学到的信息就失真了。这个坑我在早期项目中踩过当时训练loss下降得很慢排查了半天才发现是重复softmax的问题。3.2 标签的格式为什么不需要one-hot另一个常见误区是标签格式。nn.CrossEntropyLoss接受的标签是类别索引不是one-hot编码。也就是说如果真实标签是数字3你传3就行了不需要传[0,0,0,1,0,0,0,0,0,0]。# 正确传类别索引 labels torch.tensor([3, 7, 1, 9]) # 错误传one-hot编码会报错或结果不对 # labels_onehot torch.tensor([[0,0,0,1,0,0,0,0,0,0], ...])这个设计其实很合理类别索引更节省内存而且计算时PyTorch内部会自动取出真实标签对应的预测概率不需要真的构造一个one-hot向量再做点积。但如果你从其他框架转过来或者自己手写损失函数就需要注意这个区别。3.3 手写交叉熵理解内部计算过程为了彻底搞懂交叉熵我建议你自己手写一遍。不用PyTorch的现成函数就用最基础的张量操作实现一次。这样你就能清楚地看到每一步在算什么。import torch import torch.nn.functional as F def manual_cross_entropy(logits, labels): # 第一步对logits做softmax得到概率分布 probs F.softmax(logits, dim1) # 第二步取出每个样本真实标签对应的概率 batch_size logits.shape[0] true_probs probs[range(batch_size), labels] # 第三步取负对数然后求平均 loss -torch.log(true_probs).mean() return loss # 验证和PyTorch官方实现对比 logits torch.randn(4, 10) labels torch.tensor([3, 7, 1, 9]) official_loss nn.CrossEntropyLoss()(logits, labels) manual_loss manual_cross_entropy(logits, labels) print(f官方实现: {official_loss.item():.6f}) print(f手写实现: {manual_loss.item():.6f})跑一下你会发现两个值几乎一模一样可能有极小的数值误差。手写一遍的好处是你以后再看到交叉熵的公式脑子里能立刻浮现出这几行代码而不是一堆抽象的数学符号。提示实际使用中还是推荐用nn.CrossEntropyLoss因为它内部做了数值稳定性优化。比如当logits很大时直接算softmax可能会溢出而官方实现用了log-sum-exp技巧来避免这个问题。手写版本主要用于理解原理生产环境还是用官方的。3.4 数值稳定性一个容易被忽略的工程细节说到数值稳定性这里展开讲一下。假设logits里有一个值是1000那么exp(1000)会直接溢出成无穷大softmax就废了。PyTorch的nn.CrossEntropyLoss内部会先减去logits中的最大值再做指数运算这样就不会溢出。# 不稳定的写法 probs torch.exp(logits) / torch.exp(logits).sum(dim1, keepdimTrue) # 稳定的写法减最大值 logits_max logits.max(dim1, keepdimTrue)[0] logits_stable logits - logits_max probs torch.exp(logits_stable) / torch.exp(logits_stable).sum(dim1, keepdimTrue)这个细节在MNIST这种小任务上可能体现不出来因为logits通常不会太大。但如果你做的是更复杂的任务或者网络很深、学习率设得比较大logits可能会变得很大这时候数值稳定性就很重要了。养成好习惯理解官方实现为什么这么设计比只会调包要强得多。4. 损失函数选型与调参MNIST实战中的决策逻辑4.1 多分类任务的损失函数家族虽然交叉熵是分类任务的首选但它不是唯一的选择。在不同的场景下还有其他损失函数可以考虑。我把常见的几种列出来方便你对比损失函数适用场景特点MNIST是否适用CrossEntropyLoss单标签多分类softmax交叉熵梯度简洁是首选NLLLoss已做log_softmax需要配合LogSoftmax层是但不常用BCEWithLogitsLoss二分类或多标签sigmoid二元交叉熵不适用MNIST是单标签MSELoss回归任务均方误差不推荐收敛慢KLDivLoss分布匹配衡量两个分布的差异特殊场景可用对于MNIST这种标准的单标签多分类任务CrossEntropyLoss就是最优解没有之一。你不需要纠结选哪个直接用就对了。但理解其他损失函数的适用场景能帮助你在遇到新问题时快速判断该用什么。4.2 学习率与损失函数的配合关系损失函数选对了学习率设错了照样训不好。这两者是配合关系。交叉熵损失在预测错误时给出的梯度比较大所以学习率可以适当设小一点比如0.01或0.001。如果学习率太大梯度更新过猛损失可能会震荡甚至发散。我在MNIST上做过一组对比实验固定其他条件只改学习率观察损失下降情况学习率第1个epoch损失第5个epoch损失最终准确率0.10.850.3296.2%0.010.520.1597.8%0.0010.680.2197.1%0.00011.120.4595.3%可以看到学习率0.01时效果最好。太大0.1会导致震荡太小0.0001收敛太慢。当然这个结论只针对这个具体配置换一个网络结构或batch size最优学习率可能会变。但思路是一样的损失函数决定了梯度的“形状”学习率决定了每次沿梯度走多远两者必须匹配。4.3 batch size对损失值的影响还有一个容易被忽略的因素是batch size。nn.CrossEntropyLoss默认会对batch内所有样本的损失求平均。所以batch size越大损失值越平滑但每个epoch的参数更新次数越少。# batch_size64时损失是64个样本的平均 # batch_size256时损失是256个样本的平均 # 两者数值上可能有差异但趋势应该一致我个人的经验是MNIST任务用64或128的batch size比较合适。太小比如8会导致损失震荡严重太大比如1024会导致每个epoch更新次数太少收敛慢。而且batch size和损失值的关系是batch size越大损失值的方差越小曲线越平滑但不要因为曲线好看就盲目调大batch size。4.4 从损失曲线诊断训练问题损失曲线是训练过程中最有价值的诊断工具。不同的曲线形态对应不同的问题损失持续下降但很慢学习率可能太小或者模型容量不够。损失震荡剧烈学习率太大或者batch size太小。损失下降后又上升可能过拟合了或者学习率在后期太大。损失几乎不变模型可能没学到东西检查数据预处理、标签是否正确。损失变成NaN数值不稳定检查是否有除零、log(0)等情况。我在一次实验中遇到过损失突然变成NaN的情况排查后发现是学习率设成了0.5导致参数更新过大logits溢出。把学习率降到0.01就正常了。所以看到NaN不要慌先检查学习率和数值稳定性。5. 超越交叉熵标签平滑与损失函数的进阶玩法5.1 标签平滑为什么要把标签“弄模糊”标准的交叉熵损失使用one-hot标签也就是说真实标签位置是1其他位置是0。这会导致模型拼命把真实标签的概率往1推把其他标签的概率往0推。但这样容易导致过拟合模型对训练样本过于自信遇到没见过的样本反而表现不好。标签平滑Label Smoothing的思路是不要把标签设成绝对的1和0而是设成比如0.9和0.0110个类别时其他9个类别各分0.1/9≈0.011。这样模型就不会那么极端泛化能力更好。# PyTorch中可以直接用label_smoothing参数 criterion nn.CrossEntropyLoss(label_smoothing0.1)这个0.1就是平滑系数表示真实标签的概率从1降到0.9剩下的0.1平均分给其他类别。在MNIST上标签平滑可能不会带来特别明显的提升因为MNIST本身比较简单。但在更复杂的任务上标签平滑往往是涨点利器。5.2 焦点损失让模型关注“难样本”另一个进阶玩法是焦点损失Focal Loss。它的核心思想是对于那些模型已经预测得很准的样本降低它们的损失权重对于那些模型预测不准的难样本保持高权重。这样模型就能更专注于学习那些难分类的样本。焦点损失的公式是在交叉熵前面乘一个调节因子(1 - p)^gamma其中p是预测概率gamma是一个超参数。当p接近1时预测很准调节因子接近0损失被压低当p接近0时预测不准调节因子接近1损失保持。在MNIST这种类别平衡、难度均匀的任务上焦点损失的优势不明显。但如果你遇到类别极度不平衡的数据集比如某些数字的样本特别少焦点损失就能派上用场。5.3 损失函数的组合使用有时候单一损失函数不够用可以把多个损失函数组合起来。比如在MNIST上你可以把交叉熵损失和一个正则化项加起来# 交叉熵损失 L2正则化 criterion nn.CrossEntropyLoss() optimizer torch.optim.SGD(model.parameters(), lr0.01, weight_decay1e-4)这里的weight_decay其实就是L2正则化它会在损失里加上参数平方和的惩罚项。虽然PyTorch把它放在优化器里而不是损失函数里但本质上是对损失函数的一种扩展。组合使用的关键是平衡各项的权重如果正则化太强模型会欠拟合太弱则起不到正则化效果。6. 我踩过的那些损失函数相关的坑6.1 重复softmax一个隐蔽的错误前面提过nn.CrossEntropyLoss内部已经包含了softmax。但我在早期项目中因为模型定义里加了softmax层损失函数又用了CrossEntropyLoss导致做了两次softmax。这个错误很隐蔽因为代码不会报错损失也能下降只是下降得慢、最终准确率低。排查方法很简单检查模型最后一层有没有nn.Softmax如果有要么去掉它要么把损失函数换成nn.NLLLoss配合nn.LogSoftmax使用。我现在的习惯是模型最后一层永远不加softmax损失函数永远用CrossEntropyLoss这样就不会出错。6.2 标签类型不匹配LongTensor vs FloatTensornn.CrossEntropyLoss要求标签是LongTensor类型如果你传了FloatTensor会报类型错误。这个错误在数据加载时容易出现因为从DataLoader取出来的标签默认可能是其他类型。# 确保标签是LongTensor labels labels.long()还有一个相关的问题是标签的取值范围。MNIST的标签是0到9如果你不小心把标签做了归一化或者偏移比如变成了1到10就会索引越界或者算错。我在一次数据预处理中把标签从0-9映射到了1-10结果训练时损失一直很高排查了半天才发现是标签范围的问题。6.3 损失值不下降排查思路损失值不下降是最常见的问题之一。我的排查顺序是这样的检查数据输入图片是否归一化标签是否正确对应。检查模型输出打印logits看看有没有异常值全0、全相同、NaN等。检查损失函数是否重复softmax标签类型是否正确。检查学习率是否太小导致更新不动或者太大导致震荡。检查梯度打印梯度看看是否全0梯度消失或爆炸。这个排查链路我走过很多次大部分问题都能在前三步定位到。关键是要有耐心一步步缩小范围不要一上来就改模型结构。6.4 训练集损失下降但验证集损失上升这是典型的过拟合现象。损失函数本身没问题但模型在训练集上学得太“死”了。解决方法包括增加正则化L2、Dropout、减少模型参数、增加训练数据、使用早停策略。在MNIST上因为数据集比较小60000张训练图过拟合其实不算严重。但如果你用了一个很大的模型比如很深的卷积网络过拟合就可能出现。我一般会在训练时同时记录训练集和验证集的损失一旦验证集损失连续几个epoch不下降就停止训练。7. 从MNIST到真实项目损失函数思维的迁移MNIST虽然简单但它涵盖的损失函数知识是通用的。你在MNIST上学到的交叉熵、softmax、数值稳定性、损失曲线诊断换到任何分类任务上都适用。区别只在于类别数可能从10变成1000输入可能从28x28的灰度图变成224x224的彩色图但损失函数的计算逻辑是一样的。我后来做过一个文本分类项目类别有20个用的还是CrossEntropyLoss只是把输出维度从10改成20。也做过一个多标签分类项目一张图可能同时属于多个类别这时候就不能用CrossEntropyLoss了得换成BCEWithLogitsLoss。但底层的思想——用损失函数衡量预测和真实的差距用梯度下降来缩小这个差距——是完全一致的。所以我的建议是不要因为MNIST简单就跳过它。把MNIST上的每一个细节都搞透包括损失函数的数学原理、代码实现、调参技巧、常见坑这些经验在你后面做更复杂的项目时会一次次帮到你。损失函数看起来只是几行代码但它背后连接的是整个优化理论值得花时间深入理解。最后分享一个我自己的习惯每次遇到新的分类任务我会先跑一个baseline用最简单的交叉熵损失看看损失曲线和准确率。如果效果不好再考虑换损失函数或者加正则化。不要一上来就上各种高级技巧先把基础打牢大部分问题用交叉熵都能解决。

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

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

免费获取报价 →
↑