从交叉熵到SupCon:监督对比学习原理与PyTorch实战

发布时间:2026/10/29 11:57:32
从交叉熵到SupCon:监督对比学习原理与PyTorch实战

1. 从交叉熵到对比学习:我为什么盯上了SupCon

第一次认真读SupCon(Supervised Contrastive Learning)那篇工作,是在做一个细粒度图像分类的小项目上。当时模型在训练集上已经压到很低的损失,验证集准确率却卡在一个不上不下的位置,怎么调学习率、加数据增强都收效甚微。后来把分类头换掉、改用监督对比损失做预训练,再冻结特征接一个线性分类器,验证集指标直接往上跳了几个点。那一刻我才真正意识到,SupCon不是又一个“论文里的花活”,而是能实打实解决表征质量问题的工具。

SupCon的核心一句话就能说清:把同一类别的样本在特征空间里拉近,把不同类别的样本推远,而且这个“拉近推远”是在归一化后的球面上做的。它和自监督对比学习(比如SimCLR那一路)最大的区别在于,正样本不再依赖同一张图的不同增广视图,而是直接来自标签——同类即正,异类即负。这个改动看起来小,实际影响很大:标签信息被直接注入到表征学习里,避免了自监督方法里“把同一张图的两种增广当成唯一正样本”带来的语义浪费。

这篇内容适合谁看?如果你已经能跑通一个普通的图像分类流程,知道交叉熵、softmax、特征归一化这些概念,但对比学习的损失函数一看就头大,那正好。我会从设计动机讲起,把温度系数、正负样本对构造、梯度行为这些容易含糊的地方拆开,再给一份能直接跑的PyTorch实现和调参记录。全程按我自己的理解路径来,不追求教科书式的完整,但求每个关键选择都说清楚“为什么这么干”。

2. 整体设计思路:SupCon到底在优化什么

2.1 从交叉熵的局限说起

普通分类网络的结构是“特征提取器 + 线性分类头”,损失函数是交叉熵。交叉熵本质上是在优化类别的决策边界,它关心的是“这个样本被分到哪一类”,而不太关心特征空间本身的几何结构。这就带来一个现象:模型可能把两类样本分对了,但它们在特征空间里挤得很近,边界很脆弱,稍微换个域、加点噪声就崩了。

我踩过的一个典型坑是:在一个类别数不多但类内差异很大的数据集上,交叉熵训出来的特征,同类样本散得像一盘沙,类中心根本不聚。这时候你去看混淆矩阵,错分的往往就是那些“类内方差大”的类别。SupCon的思路是绕开决策边界,直接对特征空间下手——让同类特征抱团、异类特征分离,分类头反而成了附属品。

2.2 为什么是“监督”而不是“自监督”

自监督对比学习(SimCLR、MoCo这一系)的正样本来自同一张图的不同增广,负样本来自batch里的其他图。问题在于:如果batch里恰好有两张不同图但属于同一类,自监督会把它们当负样本推开,这是语义上的错误。SupCon用标签定义正负,同类即正,彻底避开了这个矛盾。

从信息量角度看,监督信号相当于给对比学习加了一个“语义先验”。自监督需要靠大量数据和强增广来“猜”哪些样本该近,SupCon直接告诉你答案。实测下来,在标注数据充足的情况下,SupCon收敛更快、最终表征的线性可分性更好。当然代价是需要标签,这也是它和自监督方法最本质的分工。

2.3 损失函数的结构拆解

SupCon的损失形式是这样的:对一个锚点样本i,取它的所有同类样本(包括自己之外的同类)作为正样本集合P(i),batch内其余样本作为负样本。对每个正样本p计算一个“锚点-正样本”的相似度,再对所有负样本算相似度,做softmax归一化后取负对数,最后对正样本求平均。

用公式表达(这是理解的关键,我尽量说人话):

L_i = -1/|P(i)| * Σ_{p∈P(i)} log( exp(z_i·z_p/τ) / Σ_{a≠i} exp(z_i·z_a/τ) )

其中z是归一化后的特征,τ是温度系数。注意分母里a遍历的是batch内除i以外的所有样本,既包含正也包含负。这个结构和InfoNCE很像,区别就是正样本可以有很多个,而不是只有一个。

提示:很多人第一次看会疑惑“为什么分母不把正样本单独拎出来”。其实分母包含全部样本是softmax的标准形式,正样本在分子里被拉高、在分母里也参与竞争,最终效果是“正样本相对所有其他样本的概率越大越好”。这个设计让损失对正样本数量不敏感,一个锚点有多个正样本时取平均即可。

2.4 和 triplet loss、center loss 的取舍

说到“拉近推远”,很多人第一反应是triplet loss。我早期也用过triplet,最大的痛点是三元组的采样策略极其敏感——采得不好模型根本不收敛,采得太狠又容易过拟合到难样本。SupCon相当于把“一个锚点对一个正样本”扩展成“一个锚点对全部同类正样本”,并且用softmax的形式把所有负样本都纳入竞争,省掉了显式的难样本挖掘。

center loss是另一条路,它给每个类维护一个中心,把样本往中心拉。问题是center loss只拉近不推远,类间分离靠交叉熵间接实现,遇到类间相似度高的场景就力不从心。SupCon同时管拉近和推远,几何约束更完整。我个人的排序是:数据干净、类别清晰时SupCon最省心;极端长尾场景下可能还要配合重采样,这个后面细说。

3. 核心细节解析:温度、归一化与正负样本构造

3.1 温度系数τ:最容易被低估的超参

温度系数τ控制softmax的“锐度”。τ越小,softmax越尖锐,模型越关注那些“相似度接近”的难负样本;τ越大,分布越平滑,梯度更温和。原论文里τ取0.1左右,我在自己的实验里试过0.05到0.5的区间,结论是:τ太小容易训练不稳定,τ太大表征区分度不够。

具体怎么理解?把相似度除以τ再进softmax,相当于把相似度差距放大了1/τ倍。τ=0.1时,两个相似度差0.1的样本,在logit层面差距被放大到1,softmax会明显偏向更相似的那个。这带来的副作用是:如果batch里有特别难的负样本(相似度很高但类别不同),梯度会非常大,可能把表征拽偏。我遇到过一次τ=0.05时loss剧烈震荡,后来调到0.1就稳了。

τ取值训练稳定性表征区分度适用场景
0.05较差,易震荡很高类别差异极大、数据干净
0.1稳定高通用默认值
0.2很稳中等类间相似度高、噪声多
0.5很稳偏低仅作消融对照

注意:τ和batch size是耦合的。batch越大,负样本越多,τ可以适当调小以增强区分;batch小的时候负样本少,τ调太小会让softmax过度集中在少数几个负样本上,反而有害。我一般按“batch 256配τ=0.1,batch 64配τ=0.15”这个经验起步。

3.2 特征归一化:球面上的几何

SupCon要求特征先做L2归一化,再算点积。这一步不是可选项,是必须的。原因在于:如果不归一化,模型可以通过“把特征模长整体放大”来降低损失,因为点积会随模长增大而增大,softmax会饱和,梯度消失。归一化之后,所有特征落在单位球面上,点积等价于余弦相似度,损失只关心方向不关心模长,优化目标才干净。

我在实现时踩过一个坑:归一化要放在投影头之后、损失计算之前,而不是在骨干网络输出处。原论文用的是“骨干 + 投影头”的两段结构,投影头是一个小的MLP,损失作用在投影头的输出上,而下游任务用的是骨干输出。这个设计的逻辑是:投影头负责把特征映射到适合对比学习的空间,骨干保留更通用的表征。如果你直接把损失加在骨干输出上,下游迁移效果会打折扣。

3.3 正负样本对的构造细节

batch内构造正负样本时,有几个细节容易出错。第一,锚点自己要从正样本集合里排除,否则“自己和自己相似度为1”会主导损失,模型学不到东西。第二,同类样本数可能为1(batch里该类只有一个样本),这时正样本集合为空,该锚点的损失要跳过,不能强行算。第三,标签要转成one-hot或直接用mask矩阵,避免循环里做字符串比较。

我习惯用mask矩阵一次性算完:先算batch内所有样本两两的余弦相似度矩阵S(N×N),再构造标签相等矩阵M(M_ij=1表示i和j同类)。正样本mask就是M减去对角线,负样本mask就是1-M再减去对角线。这样向量化之后没有Python循环,速度快很多,也避免了边界条件的遗漏。

import torch import torch.nn.functional as F def supcon_loss(features, labels, temperature=0.1): # features: [N, D], 已做L2归一化 N = features.size(0) sim = features @ features.t() / temperature # [N, N] # 数值稳定:减去每行最大值 sim = sim - sim.max(dim=1, keepdim=True)[0].detach() # 同类mask,排除对角线 labels = labels.view(-1, 1) mask = torch.eq(labels, labels.t()).float() logits_mask = torch.ones_like(mask) - torch.eye(N, device=mask.device) mask = mask * logits_mask # 分母:所有非自身样本 exp_sim = torch.exp(sim) * logits_mask log_prob = sim - torch.log(exp_sim.sum(dim=1, keepdim=True) + 1e-12) # 分子:只对正样本求平均 pos_count = mask.sum(dim=1) loss = -(mask * log_prob).sum(dim=1) / (pos_count + 1e-12) # 跳过没有正样本的锚点 valid = pos_count > 0 return loss[valid].mean()

这段代码我用了很久,几个关键点都在注释里标了。sim - sim.max(...)是为了防止exp溢出,logits_mask排除自身,pos_count处理无正样本的情况。实测在batch 256、τ=0.1下很稳。

3.4 投影头的结构选择

投影头一般用两层MLP,中间加ReLU,输出维度通常比骨干特征小(比如骨干512维,投影头输出128维)。为什么输出维度要小?因为对比学习只需要一个“适合做相似度比较”的低维空间,维度太高反而容易过拟合到batch内的噪声。我试过投影头输出256维和128维,差别不大,但128维训练更快。

还有一个细节:投影头之后要不要再加归一化?要。投影头输出先过L2归一化再进损失,这是标准做法。下游任务时把投影头整个丢掉,只用骨干输出接线性分类器,这个“预训练-微调”的分离是SupCon能work的关键之一。

4. 实操过程:从零搭一个SupCon训练流程

4.1 数据准备与增广策略

SupCon虽然用标签定义正负,但数据增广依然重要。增广的作用是让模型见到同一类别的更多“视角”,增强类内鲁棒性。我常用的增广组合是:随机裁剪加缩放、随机水平翻转、颜色抖动(亮度、对比度、饱和度各0.4)、随机灰度化(概率0.2)。注意不要用太强的增广,比如大幅旋转或CutMix,因为SupCon的正样本是“不同图但同类”,强增广可能把类内差异放大到超过类间差异,反而有害。

数据加载时有个实操技巧:保证每个batch里每个类别至少有2个样本。如果随机采样导致某类只有1个样本,这个锚点的正样本集合为空,损失被跳过,等于浪费了计算。我的做法是用类别平衡采样器,或者干脆按“每类采样k个”的方式构造batch。k取2到4比较合适,太大则batch内类别数变少,负样本多样性下降。

4.2 训练流程与学习率调度

训练分两阶段:第一阶段用SupCon损失预训练骨干加投影头;第二阶段丢掉投影头,冻结或微调骨干,接线性分类器用交叉熵训练。第一阶段的学习率我一般设1e-3(Adam)或0.1(SGD加动量0.9),warmup 10个epoch,之后余弦退火。第二阶段线性分类器的学习率可以大一些,1e-2起步,骨干微调的话用1e-4。

这里有个经验:预训练阶段不要过早停。SupCon的损失下降曲线比交叉熵平缓,前几十个epoch可能看起来“没怎么降”,但表征质量在持续改善。我一般至少训200个epoch,数据量小的话300到500个epoch。判断收敛不看loss绝对值,看下游线性探针的准确率是否还在涨。

4.3 一个完整的训练循环骨架

# 伪代码,突出关键流程 for epoch in range(epochs): for images, labels in loader: images = images.to(device) labels = labels.to(device) # 两次增广,各自过网络 feat1 = backbone(aug1(images)) feat2 = backbone(aug2(images)) proj1 = F.normalize(projection_head(feat1), dim=1) proj2 = F.normalize(projection_head(feat2), dim=1) # 拼接后一起算损失,正样本翻倍 feats = torch.cat([proj1, proj2], dim=0) labs = torch.cat([labels, labels], dim=0) loss = supcon_loss(feats, labs, temperature=0.1) optimizer.zero_grad() loss.backward() optimizer.step()

注意这里对每张图做了两次增广,然后把两份特征拼起来算损失。这样每个锚点的正样本数量翻倍,batch等效扩大,负样本也更丰富。这是原论文的推荐做法,我实测确实比单次增广效果好,代价是显存占用翻倍。显存紧张的话可以只做一次增广,但batch要相应调大。

4.4 下游评估:线性探针与微调

评估SupCon表征质量的标准做法是线性探针:冻结骨干,只训练一个线性分类器。这个协议能排除分类头容量带来的干扰,纯粹衡量特征的可分性。我的流程是:预训练结束后,用训练集提取所有特征,训练一个逻辑回归或单层线性层,在验证集上看准确率。如果线性探针准确率高,说明表征本身好;如果微调后准确率高但线性探针低,说明表征需要靠非线性头来“救”,质量一般。

微调时有个小技巧:分层设置学习率。骨干用较小的学习率(1e-4),分类头用较大的(1e-2),这样既保留预训练学到的通用特征,又让分类头快速适配。如果数据量和预训练数据分布差异大,可以只微调最后几个block,前面的冻结。

5. 常见问题与排查实录

5.1 损失不降或剧烈震荡怎么办

这是最高频的问题。我按排查顺序列一下:先看温度系数,τ太小是震荡的头号嫌疑,试着调到0.15或0.2;再看学习率,SupCon对学习率比交叉熵敏感,1e-3不行就降到5e-4;然后检查归一化有没有漏,特征没归一化会导致点积爆炸;最后看batch里是不是有类别只有一个样本,导致大量锚点被跳过,有效batch变小。

还有一种隐蔽情况:数据里有标签噪声。SupCon对标签噪声比交叉熵更敏感,因为噪声标签会直接把异类样本当正样本拉近,污染特征空间。如果怀疑标签有问题,可以先在小规模干净数据上验证流程,再逐步扩大。

5.2 显存不够怎么压缩

SupCon的显存开销主要来自两处:两次增广的双份前向、以及N×N的相似度矩阵。压缩手段有几个:把两次增广改成一次(牺牲一点效果);用梯度累积模拟大batch(但注意相似度矩阵还是按实际batch算,负样本数不会变多);把投影头输出维度从128降到64;用混合精度训练。我一般优先用混合精度加梯度累积,实测显存能省40%左右,效果损失很小。

5.3 类别极多时正样本太少

类别数上千、每类样本又少的长尾场景,batch里同类样本可能凑不齐。这时候有两个思路:一是用类平衡采样保证每类至少2个;二是退化成“类中心对比”,给每个类维护一个动量更新的类中心,把类中心也当作正样本。后者是我在一个长尾项目里的变通做法,效果比纯SupCon稳,但实现复杂度高一些。

5.4 常见问题速查表

现象可能原因排查动作
loss震荡大τ过小或学习率过高τ调到0.15,学习率减半
loss不降特征未归一化或正样本为空检查normalize,检查batch类别分布
线性探针准确率低预训练不充分或增广过强延长epoch,减弱颜色抖动
微调后过拟合骨干学习率过高骨干降到1e-4,冻结前几层
训练慢相似度矩阵未向量化用mask矩阵替代循环

5.5 几个我踩过的坑

第一个坑是把SupCon损失和交叉熵直接相加。早期我想“两个损失一起用总没坏处”,结果两个目标互相拉扯,效果反而不如单用SupCon预训练再接交叉熵。后来明白:SupCon优化的是特征几何,交叉熵优化的是决策边界,同时优化会让骨干在两种目标间摇摆。正确做法是分阶段。

第二个坑是投影头输出维度设得和骨干一样大。我一开始图省事,投影头输出512维和骨干一致,结果对比学习空间维度太高,batch内噪声被放大,线性探针准确率反而低。降到128维后明显改善。

第三个坑是评估时忘了切eval模式。骨干里有BatchNorm,训练模式下的统计量会污染线性探针的特征提取。这个错误很蠢但很常见,尤其是快速实验时容易忘。

6. 我对SupCon适用边界的个人判断

SupCon不是万能的。它在“类别定义清晰、标注质量高、类内差异适中”的场景下表现最好,比如细粒度分类、人脸表征、检索任务。反过来,如果标签噪声大、类别边界模糊(比如某些主观标注任务),SupCon的“同类拉近”会放大噪声,这时候交叉熵反而更鲁棒,因为交叉熵对单个样本的错误容忍度更高。

另一个边界是数据量。SupCon需要足够的同类样本才能形成有效的正样本对,每类样本太少时(比如每类不到10个),正样本对稀缺,效果会打折扣。这种场景下,自监督预训练加少量标注微调可能是更实际的选择。

我在实际项目里的做法是:先用交叉熵跑一个baseline,如果发现特征空间类内散、类间挤,再上SupCon做预训练对比。不要一上来就迷信对比学习,baseline的诊断价值很大。另外,SupCon的调参成本比交叉熵高,温度、batch、增广强度都要试,时间预算紧的话要权衡。

最后分享一个我常用的小技巧:训练SupCon时,每隔几个epoch把当前batch的特征降维画个散点图(用PCA或t-SNE),肉眼看一下同类是不是在抱团。这个可视化比看loss曲线直观得多,能提前发现“loss在降但特征没聚”的假收敛。踩过几次坑之后,我现在基本把这个可视化当成标配监控项。