三、分类损失函数:交叉熵损失、Hinge损失与平方合页损失
分类任务最核心的问题是什么?
让模型输出的分数和真实标签“对齐”。但怎么才叫对齐?不同的损失函数,定义了不同的对齐标准。我这些年调模型踩得最深的坑,往往是选错了损失函数,结果模型怎么训都不收敛。下面我们一个一个掰开来看。
3.1 交叉熵损失(Cross-Entropy Loss)
这是目前分类问题里最常用的损失。它的核心思想是:把模型输出的 logits 转成概率分布(softmax),然后和真实的 one-hot 标签算 KL 散度。
公式很简洁:
L_CE = - Σ y_i * log(p_i)
其中 y_i 是真实标签(0或1),p_i 是 softmax 后的概率。对于二分类,退化为:
L_BCE = - [y * log(p) + (1 - y) * log(1 - p)]
nn.CrossEntropyLoss(PyTorch)或 tf.losses.SparseCategoricalCrossentropy。这些 API 内部已经把 softmax 和损失计算合并了,数值更稳定。
交叉熵的优点很明显——梯度形式简单,更新方向正确。尤其是当模型预测和真实标签差距很大时,损失会很大,梯度也很大,能快速拉回模型。
但有一个坑:对噪声标签极度敏感。我曾经在一个医疗影像数据集上,因为标注员标错了几百张图,交叉熵直接让模型过拟合到那些噪声上,准确率死活上不去。后来换了鲁棒损失才搞定。
nn.Linear(..., bias=False),然后手动把 bias 初始化为 torch.log(1/1000),loss 立马往下掉。
3.2 Hinge损失(Hinge Loss)
Hinge 损失最早用在大间隔分类器(SVM)里。它的想法是:我不光要让分类正确,还要让正确类的分数比其他类高出至少一个“边距”(通常取1)。
对于二分类,标签 y ∈ {−1, +1},输出分数 f(x):
L_hinge = max(0, 1 - y * f(x))
看公式就知道:如果 y*f(x) ≥ 1,损失为0;否则损失线性增长。它只惩罚那些“不够安全”的样本。
| 特性 | 交叉熵 | Hinge |
|---|---|---|
| 输出解释性 | 概率意义明确 | 分数无概率解释 |
| 对离群点敏感度 | 高(指数惩罚) | 中(线性惩罚) |
| 梯度特性 | 处处非零梯度 | 对正确样本梯度为0 |
| 常用场景 | 深度学习分类、语言模型 | SVM、度量学习 |
你想想看,Hinge 损失会让模型只关注边界附近的样本(支持向量)。这在小样本场景下反而能防止过拟合。但它在深度网络里不太流行,因为梯度很稀疏——很多样本的损失已经是0了,模型不再从它们身上学习。
避坑指南:我在做人脸识别时试过用 Hinge 损失来训练分类器,结果发现训练过程中大量样本的 loss 都是0,模型几乎不更新,收敛极慢。后来改成加一个 temperature 缩放后再算 Hinge,才缓解了这个问题。
3.3 平方合页损失(Squared Hinge Loss)
这是 Hinge 损失的一个变体。区别在于:当样本违规时,惩罚不再是线性,而是平方。
L_squared_hinge = max(0, 1 - y * f(x))^2
为什么会有这个版本?因为在 Hinge 损失下,离群点会产生巨大的梯度,偶尔会把模型带偏。平方项让监督惩罚更平滑,梯度是 2·max(0, 1-yf),而不是恒为 0 或 1。
我对比过三者在 CIFAR-10 上的训练曲线:
# 伪代码示意
import torch.nn as nn
# 交叉熵
ce_loss = nn.CrossEntropyLoss()(logits, labels)
# Hinge(需手动实现)
scores = logits.gather(1, labels.unsqueeze(1)).squeeze()
other_scores = logits.max(1)[0] # 近似,实际需减去正确类
hinge_loss = torch.clamp(1 - scores + other_scores, min=0).mean()
# Squared Hinge
squared_hinge_loss = (torch.clamp(1 - scores + other_scores, min=0) ** 2).mean()
我项目中实际的选择策略:如果标签干净、类别均衡,无脑用交叉熵。如果标签有噪声、类别不均衡,可以试试平方合页。如果模型需要输出概率,则交叉熵是必选项。
3.4 三者的对比与选择逻辑
现在我们把三种损失的核心差异画成一张图,方便你建立直观感受。
3.5 实际中的典型代码配置
我用 PyTorch 比较多,给你看看常用的写法:
# 交叉熵(最常用)
criterion_ce = nn.CrossEntropyLoss()
loss = criterion_ce(logits, labels)
# Hinge(二分类,标签需转为±1)
def hinge_loss(logits, labels_binary):
# labels_binary: -1 或 1
return torch.mean(torch.clamp(1 - labels_binary * logits, min=0))
# 平方合页
def squared_hinge_loss(logits, labels_binary):
return torch.mean(torch.clamp(1 - labels_binary * logits, min=0) ** 2)
注意:对于多分类的 Hinge,常见做法是:正确类的分数至少要比任何错误类的分数大1,即多分类 Hinge 损失:
def multiclass_hinge(logits, labels):
# labels: 0..C-1
batch_size = logits.size(0)
correct_scores = logits.gather(1, labels.unsqueeze(1)).squeeze(1)
# 计算错误类中最大的分数
wrong_scores = logits.clone()
wrong_scores[range(batch_size), labels] = -1e9
max_wrong = wrong_scores.max(dim=1)[0]
loss = torch.clamp(1 - correct_scores + max_wrong, min=0).mean()
return loss
小心数值精度!当 correct_scores 远大于 max_wrong 时,clamp 全部取0,梯度消失。如果你发现 loss 突然跳到0且不再变化,别担心,那只意味着模型已经把所有样本都分类正确且间隔足够大——训练可以停了。
3.6 训练稳定性的经验分享
损失函数不仅影响最终精度,更影响训练过程的稳定性。我自己的经验是:
- 交叉熵:最稳定,学习率不好调时也基本能收敛。但如果出现 loss 震荡,可以考虑
label smoothing。 - Hinge:配合 SGD 不加 momentum 时,训练曲线容易骤降骤升。建议用 Adam 或带 momentum 的 SGD。
- 平方合页:梯度平方后比 Hinge 更平滑,但 Loss 值会偏大。需要适当降低学习率(我通常降到 Hinge 学习率的 1/2)。
记得有一次我切换了一个数据集,把分类损失从交叉熵换成平方合页,LR 忘了改,结果 loss 直接炸到 NaN。排查半天才发现问题。所以,切换损失函数后,务必重新扫一遍学习率。
好了,三种分类损失就讲到这里。你需要记住的是:没有绝对最好的损失,只有最合适的场景。下回遇到分类问题,先问问自己——你的标签干净吗?你需要概率输出吗?你对间隔有要求吗?答案会帮你选出正确的那个。