首页 / 资讯中心 / 文章详情

从手写数字识别深入理解交叉熵损失函数原理与实战

从手写数字识别深入理解交叉熵损失函数原理与实战 ★ FEATURED ARTICLE
1. 从“猜数字”说起损失函数到底在做什么很多人第一次接触手写数字识别注意力全在“网络结构怎么搭”“准确率能到多少”上损失函数往往被当成一行代码随手带过。我刚开始学的时候也这样loss CrossEntropyLoss()一写训练跑起来就完事。直到有一次模型怎么训都不收敛排查了大半天才发现问题出在我根本没搞懂损失函数在背后干了什么。先把场景说清楚。手写数字识别要解决的问题是给一张28×28的灰度图判断它写的是0到9中的哪个数字。这是一个十分类问题。模型前向传播之后会输出10个数值我们叫它logits可以理解成模型对每个数字的“信心打分”。但打分归打分怎么把这个打分和真实标签之间的差距量化成一个可以反向传播的数值这就是损失函数的活儿。打个生活化的比方。你让一个新手去猜盒子里有几个球他猜“大概3个”实际是5个。损失函数就是那个裁判负责告诉他“你差了2个”。但裁判的判法有很多种可以直接算差值可以算差值的平方也可以算比例。不同的判法给新手的反馈方向完全不同有的温和有的严厉有的对大误差特别敏感。损失函数的选择直接决定了模型学习的“性格”。这一块内容我打算从三个层面拆开讲第一为什么分类任务不能直接用均方误差第二交叉熵损失函数的数学原理和它为什么适合分类第三在具体代码里怎么用、有哪些坑。中间会穿插我自己踩过的坑和实测数据尽量让零基础的朋友也能跟上。提示本文所有代码基于常见的深度学习框架编写思路通用换框架只需调整API名称。2. 为什么分类任务不能照搬回归的损失函数2.1 均方误差在分类任务上的尴尬回归任务里最常用的损失函数是均方误差公式很简单预测值和真实值相减平方再求平均。比如预测房价真实300万预测280万损失就是(300-280)²400。这个逻辑直观梯度也好算。但把它搬到分类任务上问题就来了。假设一张图真实标签是“3”经过独热编码之后真实向量是[0,0,0,1,0,0,0,0,0,0]。模型输出经过某种归一化后是[0.1,0.1,0.1,0.4,0.1,0.1,0.05,0.02,0.02,0.01]。用均方误差算损失是各项差值的平方和。看起来也能算对吧问题出在梯度上。均方误差对输出的梯度是线性的当输出值经过Sigmoid或Softmax这类函数压缩到0到1之间时梯度会变得非常小。我用一个具体数字说明假设某个输出节点的值接近0或1Sigmoid函数的导数在两端趋近于0。这时候均方误差传回去的梯度再乘上这个接近0的导数结果就是梯度消失参数几乎不更新。实测下来用均方误差训练分类模型收敛速度比交叉熵慢好几倍而且容易卡在局部最优。还有一个更本质的问题均方误差隐含假设了输出是高斯分布而分类任务的输出本质上是概率分布应该用描述概率分布差异的度量方式。2.2 从信息论借来的交叉熵交叉熵这个概念来自信息论原本是用来衡量两个概率分布之间差异的。公式长这样H(p, q) -Σ p(x) log q(x)其中p是真实分布q是模型预测分布。在分类任务里真实分布p就是独热编码只有正确类别那一项是1其他都是0。所以公式可以简化成H(p, q) -log q(正确类别)这个简化太关键了。它意味着交叉熵损失只关心模型对正确类别的预测概率。预测得越准q(正确类别)越接近1log之后接近0损失就小预测得越离谱q(正确类别)接近0log之后趋向负无穷取负号就变成正无穷大损失巨大。这种“只盯着正确答案”的特性让交叉熵在分类任务上天然合适。而且它的梯度形式非常漂亮对logits的梯度就是预测概率减去真实标签。这个梯度不会因为Softmax的饱和而消失因为Softmax的导数被巧妙地抵消掉了。这也是为什么交叉熵配Softmax成为分类任务的标准组合。2.3 一个直观的对比实验我之前做过一个对比实验同样的网络结构同样的数据只换损失函数。用均方误差的时候训练10轮之后准确率还在60%左右徘徊损失下降得很慢。换成交叉熵之后第3轮准确率就冲到了90%以上。这个差距不是偶然背后就是梯度性质的差异。对比维度均方误差交叉熵适用任务回归分类梯度特性容易饱和消失梯度稳定收敛速度慢快概率解释无有明确概率意义与Softmax配合不推荐标准组合注意不是说均方误差完全不能用在某些特殊场景下配合特定的输出层设计也能工作但对于标准的多分类任务交叉熵是更稳妥的选择。3. 交叉熵损失函数的代码实现与参数细节3.1 从logits到概率Softmax的角色在代码里交叉熵损失通常不会单独出现它往往和Softmax绑定在一起。Softmax的作用是把模型输出的原始logits转换成概率分布保证所有输出在0到1之间且和为1。公式是softmax(z_i) exp(z_i) / Σ exp(z_j)这个操作有个特点它会放大大的logits压制小的logits。比如两个logits是[2, 1]经过Softmax之后大约是[0.73, 0.27]。如果差距更大比如[5, 1]结果就是[0.98, 0.02]。这种“赢家通吃”的特性让模型对正确类别的预测越来越自信。但这里有个坑如果直接先算Softmax再算交叉熵数值上容易不稳定。因为exp操作可能溢出尤其是logits很大的时候。所以实际框架里通常把Softmax和交叉熵合并成一个操作用log-sum-exp技巧来保证数值稳定性。你在代码里看到的CrossEntropyLoss内部其实已经包含了Softmax所以不要在前向传播里再加一层Softmax否则就重复了。3.2 代码实操手写数字识别的损失计算下面这段代码展示了一个完整的损失计算流程我加了详细注释import torch import torch.nn as nn # 假设batch_size64类别数10 batch_size 64 num_classes 10 # 模拟模型输出的logits形状是[batch_size, num_classes] logits torch.randn(batch_size, num_classes) # 模拟真实标签形状是[batch_size]值是0到9的整数 labels torch.randint(0, num_classes, (batch_size,)) # 定义交叉熵损失函数 criterion nn.CrossEntropyLoss() # 计算损失 loss criterion(logits, labels) print(f损失值: {loss.item():.4f})这段代码里有个关键点labels是整数索引不是独热编码。CrossEntropyLoss内部会自动把整数标签转成独热形式然后计算交叉熵。如果你手动传独热编码进去反而会报错或者算错。这是新手很容易搞混的地方。另外logits的形状是[batch_size, num_classes]labels的形状是[batch_size]。维度要对齐否则会出各种奇怪的错误。我见过有人把labels的形状搞成[batch_size, 1]结果损失算出来完全不对。3.3 手动实现交叉熵理解每一步为了真正搞懂交叉熵我建议你手动实现一遍。下面是不依赖框架的版本import numpy as np def softmax(x): # 减去最大值防止溢出 x_shifted x - np.max(x, axis1, keepdimsTrue) exp_x np.exp(x_shifted) return exp_x / np.sum(exp_x, axis1, keepdimsTrue) def cross_entropy_loss(logits, labels): # 先算softmax得到概率 probs softmax(logits) # 取正确类别的概率 batch_size logits.shape[0] correct_probs probs[np.arange(batch_size), labels] # 算负对数 loss -np.mean(np.log(correct_probs 1e-10)) return loss这里加1e-10是为了防止log(0)出现负无穷。虽然实际框架内部有更精细的处理但手动实现时这个保护是必要的。跑一遍这个代码再对比框架的输出你会发现数值几乎一致这时候你对交叉熵的理解就到位了。提示手动实现主要用于理解原理实际训练还是用框架自带的损失函数因为框架版本在数值稳定性和计算效率上都做了大量优化。4. 训练过程中损失函数的常见问题与排查4.1 损失不下降的几种可能训练时最让人抓狂的就是损失一动不动。我遇到过好几次总结下来大概有这么几类原因第一类是学习率设置不当。学习率太大损失会震荡甚至发散学习率太小损失下降慢得像蜗牛。我一般会先用一个中等学习率试跑几十个batch观察损失变化。如果损失几乎不变就调大学习率如果损失上下跳动就调小。第二类是数据标签有问题。有一次我拿到的数据集标签编码方式和损失函数期望的不一致导致模型一直在学错误的目标。排查方法是打印几个batch的标签和对应的预测值肉眼看看是否合理。第三类是网络输出层设计有误。比如输出维度不是类别数或者忘记去掉最后的Softmax层导致重复计算。这些细节在代码审查时容易被忽略。4.2 损失突然变成NaNNaN是另一个常见问题。原因通常是数值溢出或除零。交叉熵里涉及log和exp如果logits数值过大exp之后可能溢出成inf再取log就变成NaN。解决办法是在Softmax之前减去最大值这个操作框架内部已经做了但如果你手动实现就要注意。还有一种情况是学习率过大导致参数更新过猛logits直接飞到很大的值。这时候可以加梯度裁剪限制梯度的最大范数。我在训练手写数字识别时一般会把梯度裁剪的阈值设在5左右效果比较稳。4.3 损失下降但准确率不涨这种情况说明模型在优化损失但学到的特征没有泛化能力。可能的原因包括过拟合、数据分布不均衡、评估指标计算有误。我一般会同时监控训练集和验证集的损失与准确率。如果训练集损失降但验证集损失升那就是过拟合需要加正则化或早停。如果两者都不涨那可能是模型容量不够或者数据本身有问题。问题现象可能原因排查方法解决思路损失不下降学习率不当观察损失曲线调整学习率损失变NaN数值溢出检查logits范围加梯度裁剪损失降但准确率不涨过拟合对比训练验证曲线加正则化损失震荡学习率过大观察损失波动减小学习率4.4 一个容易被忽略的细节reduction参数CrossEntropyLoss有个reduction参数默认是mean表示对所有样本的损失求平均。还有sum和none两个选项。sum是求和none是不做聚合返回每个样本的损失。这个参数在有些场景下很重要。比如你想对不同样本加权就需要先用none拿到每个样本的损失再手动加权求和。如果直接用默认的mean加权逻辑就不好实现。我刚开始用的时候没注意这个参数后来做类别不均衡任务时才发现它的用处。比如手写数字里数字1的样本特别多数字9特别少就可以给数字9的样本更高的权重让模型更关注少数类。5. 损失函数的选择与调优经验5.1 多分类任务的损失函数选型虽然交叉熵是多分类的标配但也不是唯一选择。有些场景下会用到其他变体带权重的交叉熵用于类别不均衡给少数类更高权重。Focal Loss用于难易样本不均衡降低易分类样本的权重让模型聚焦难样本。标签平滑交叉熵把硬标签变成软标签缓解过拟合提升泛化能力。我在手写数字识别任务上试过标签平滑把真实标签的1改成0.9其他0改成0.1/9。效果上训练集准确率略有下降但验证集准确率有提升说明泛化能力变好了。这个技巧在数据量不大或者模型容易过拟合时特别有用。5.2 损失函数与优化器的配合损失函数和优化器是搭档配合不好也会出问题。交叉熵的梯度形式是预测概率 - 真实标签这个梯度范围在-1到1之间比较温和。配合SGD或者Adam都能工作。但如果用均方误差梯度可能非常小就需要更大的学习率或者更激进的优化器。我个人的习惯是交叉熵配Adam学习率设0.001大部分情况下都能稳定收敛。如果追求更好的最终精度可以换成SGD加动量学习率设0.01配合学习率衰减策略但调参成本更高。5.3 监控损失曲线的正确姿势损失曲线是训练过程中最重要的诊断工具。我一般会关注几个点初始损失值是否合理、下降趋势是否平滑、有没有异常尖峰、训练和验证曲线的差距。对于十分类任务随机猜测的交叉熵损失是log(10)≈2.3。如果初始损失远大于这个值说明初始化有问题如果远小于可能哪里算错了。训练过程中损失应该逐渐下降并趋于平缓。如果一直下降但验证损失上升就是过拟合的信号。我习惯每隔一定步数记录一次损失然后用平滑后的曲线来看趋势。原始曲线往往抖动很大平滑之后更容易看出方向。平滑窗口一般取10到50个点太小了没效果太大了会掩盖细节。注意不要只看最终损失值过程同样重要。一个最终损失很低但中途剧烈震荡的训练往往不如一个损失稍高但平稳下降的训练可靠。6. 从损失函数延伸出去几个值得深挖的方向6.1 损失函数与模型校准模型校准是这两年比较热的话题。简单说就是模型输出的概率是否真的代表置信度。比如模型说“这张图有80%概率是3”那在所有它说80%的样本里是否真的有80%是3交叉熵训练出来的模型往往过度自信输出的概率偏高。这时候可以用温度缩放或者标签平滑来校准。我在实际项目里发现校准后的模型在决策阈值选择上更可靠尤其是需要拒绝低置信度样本的场景。6.2 自定义损失函数的注意事项有时候标准损失函数不够用需要自定义。自定义时要注意几点第一确保损失函数可导否则反向传播会断第二注意数值稳定性该加的保护要加第三先在小批量数据上验证梯度是否正确。我一般会用数值梯度检验的方法把自定义损失函数的梯度和数值近似梯度对比误差在1e-5以内才算通过。6.3 损失函数在多任务学习中的角色多任务学习里多个损失函数需要加权组合。权重怎么设是个学问。简单做法是手动调但费时费力。进阶做法是用不确定性加权或者梯度归一化。我在一个多任务项目里试过不确定性加权让模型自己学习每个任务的权重效果比手动调参好不少。不过这个方法对数据量有要求数据太少时学出来的权重不稳定。损失函数看起来只是训练流程里的一个环节但它的影响贯穿整个模型行为。选对了训练顺风顺水选错了怎么调都别扭。我自己的经验是先把交叉熵吃透理解它的来龙去脉和代码细节再去尝试其他变体这样每一步都走得踏实。手写数字识别虽然简单但作为理解损失函数的载体它足够直观也足够有代表性。把这个小任务里的损失函数搞明白了换到更复杂的任务上思路是一样的。
阅读完成 · 觉得有帮助?
咨询建站