CPL卷积原型学习:替代Softmax,提升泛化能力与置信度校准
如果你已经用 softmax 训练过很多图像分类模型大概率已经习惯“最后一层全连接 softmax 交叉熵”这个组合了。它简单、稳定、可解释性强但真的没什么可优化的空间了吗我最近在一组对比实验中把最后的 softmax 分类器换成了卷积原型学习CPLConvolutional Prototype Learning测试集准确率提升了 2 个点左右更明显的变化是面对标签噪声和分布外样本时模型不再动不动给出 99% 的“确定性错误”。这篇内容就围绕 CPL 展开它到底是什么为什么能在泛化性能上胜过常规 softmax以及如何在自己的分类任务里落地。如果你手头正好遇到分类效果差、置信度虚高、或者噪声标签多的情况这篇文章值得读完。1. 为什么想换掉 Softmax三个绕不开的痛点1.1 过度自信概率只表示相对比较不代表真实置信度Softmax 的核心操作是把一组实数 logits 转换成概率分布公式是p_i exp(z_i) / sum_j exp(z_j)。这个变换有两个天然特点一是概率总和恒为 1所有类别的预测得分是零和博弈二是指数运算会放大 logits 之间的差距。哪怕最后两层得到的 logits 只是从 1.2 变成 2.3softmax 的输出可能就从 40% 跳到 80%。这种特性带来了常见的“过度自信”现象。我见过很多次这样的场景一个被随机翻转了标签的脏样本经过模型推理softmax 输出照样给出 0.99 的置信度而且预测的恰好还是错误类别。为什么因为模型只需要在 logits 上拉开一点点差距就能产生极端概率。说白了softmax 的置信度只是“这些类里面哪一个相对更高”而不是“我对这个预测有多确定”。实际业务里如果我们要用置信度做人工审核的触发条件softmax 这种虚高置信度是非常危险的。生活里有个类比有人参加抢答比赛他只需要比其他选手举手快一点就能得到答题权并不代表他真的知道答案。Softmax 也是如此它不关心自己的判断有没有依据只要相对分数领先就“信誓旦旦”。这种机制在干净、封闭、类别均衡的数据集上问题不大但在开放环境里就成了隐患。1.2 闭集假设面对没见过的东西Softmax 只能硬猜Softmax 分类器默认世界里只有训练时出现过的 K 个类别。推理时不管你输入什么它都会在这 K 个类别里分配概率。如果来了一个完全没见过的新物种、新故障类型、新写作风格softmax 依然会把概率分散给已有类别有时还会以很高置信度预测成某个已知类。有人会说给模型加一个“未知类”标签不就行了实际操作中很难。未知类的样本长什么样完全没有先验你收集的未知类样本很可能只是冰山一角模型会把所有不认识的都塞进这个类反而干扰已知类边界。数学上也解释得通softmax 的分母要求所有类别概率加起来为 1这意味着新增一个类别会迫使原有类别的概率重新分配整个决策边界都要跟着调整。这种“闭集设定”让模型对外界一无所知也导致它很难作为真正的开放集系统使用。相比之下CPL 的分类依据是“样本特征与原型之间的距离或相似度”。如果样本距离所有原型都很远模型有理由认为它不属于任何已知类。这个逻辑天然比 softmax 更适合开放场景至少给了我们一个拒绝识别的抓手。1.3 特征空间不够紧凑分类器与特征学习脱节用交叉熵训练 softmax 分类器时骨干网络学习到的特征只需要“大致线性可分”就够了不需要类内特别紧凑。模型只要能在某个方向的分类边界上把类别分开损失就会下降。结果是特征空间中每个类别的样本常常像一团松散的云不同类别的云团之间互相靠近甚至重叠泛化就出问题。有个现象能直观印证这一点你把训练好的 softmax 模型的特征拿来计算类内平均距离通常比基于度量学习的模型大得多。因为交叉熵损失不会惩罚类内分散只惩罚分类错误的样本。换句话说模型把精力都花在“分开不同类”上没时间管“同类样本靠拢”。还有一点容易被忽略softmax 最后一层全连接的权重向量某种程度上可以看作类中心的粗糙近似。但它跟特征向量之间是做点积模型的分类边界本质上是线性的。当类别内部呈现多模态分布——比如一个“猫”类里既有橘猫又有黑猫甚至还有猫的卡通图片——单一线性的分类头就很难表达这种复杂的子结构。CPL 的思路正是从这里切入与其学一个线性分类面不如直接学习一组“原型”让特征向原型靠拢每个类用多个原型覆盖不同子簇从而让特征空间更紧凑、更可解释。2. CPL 是什么用“原型”替代“线性边界”的分类框架2.1 原型每类学一组“标准答案”“原型”这个词听着玄乎其实就是特征空间里的一个向量。你可以把它理解为每个类别的“标准答案”或者说“中心代表”。假设我们把图片输入骨干网络得到一个特征向量那么原型就是跟特征向量在同一空间里的可学习向量。模型做预测时不再用线性层把特征映射成 K 个分数而是计算特征向量和每个类别的原型有多像像的程度就是该类的分数。举个例子如果我们识别水果模型可能会学到“苹果”这个原型是一条靠近红色、圆形、高光的特征向量。当一个输入样本提取出的特征也落在附近我们就认为它属于苹果。这跟人脑的认知方式有点类似我们先形成对猫、狗、苹果的抽象模板再用模板跟新见到的东西比对而不是直接画一条数学边界。一个类一个原型是最简单的形式但现实中一个原型往往不够。以后端“猫”为例橘猫、黑猫、白猫在颜色特征上差异可能很大单个原型会被挤压在一个尴尬的位置既不像橘猫也不像黑猫。CPL 的做法是允许一个类别拥有多个原型让不同原型负责不同子簇。预测时只要样本跟这一类中的任意一个原型匹配上就足以支撑分类决策。这样既保留了原型的可解释性又不会牺牲模型的表达能力。2.2 从原型网络到卷积原型学习可学习的非参数方法说到原型学习很多人会先想起小样本学习里的原型网络Prototypical Networks。那个方案里每个类别的原型是通过对支持集样本的特征求平均算出来的是一种确定性的非参数方法不参与梯度更新。CPL 跟它最大的不同在于CPL 的原型是网络参数的一部分通过反向传播端到端学出来的。可学习原型带来几个实际好处。第一原型不必是某个子类样本的简单均值它可以被优化到更利于分类的位置甚至能学到支撑样本均值无法表达的特征方向。第二训练过程中骨干网络和原型可以协同更新特征向“适合用原型度量”的方向演化两者的适配度高很多。第三当类别数据不均衡时通过损失函数可以主动拉开原型之间的距离避免少数类被多数类挤占。“卷积”二字又是什么意思CPL 里的原型本质上可以看作一组卷积核。当我们说“用原型计算相似度”如果保持空间维度就是让原型在特征图上滑动做卷积得到每个位置的原型响应再聚合出分类分数。这种设计把分类器变成了一种“空间感知”的匹配器而不只是全局向量的点积。后面我会专门拆解这个机制。2.3 整体流程把最后的全连接层换成原型匹配层使用 CPL 时骨干网络几乎不用改。你仍然可以用常见的卷积骨干结构提取特征甚至继续使用在 ImageNet 上预训练的权重。唯一要替换的是最后的分类层把nn.Linear(feat_dim, num_classes)换成原型匹配层内部维护一组形状为[num_classes * num_prototypes, feat_dim]的可学习向量。训练时数据仍然走骨干网络得到特征再进入原型层得到每类的匹配分数最后接上交叉熵损失。因为整体框架仍然是“特征提取器 可微分类器 交叉熵”所以优化器、学习率调度、数据增强这些常规手段全部可以沿用。换句话说CPL 不是一套全新的训练体系而是一个结构替换方案这也解释了为什么它被称作 softmax 的“替代品”。推理时同样简单输入图片 → 骨干特征 → 原型匹配分数 → argmax。如果你对拒绝未知类有需求还能额外利用“距离最近原型的相似度”作为不确定度指标。下面我们就深入到核心机制讲讲相似度计算、原型更新和多原型聚合到底怎么实现。3. 核心机制拆解原型如何学习、相似度如何计算3.1 相似度计算内积、余弦与温度缩放CPL 最核心的计算是“特征-原型”相似度。最简单的实现是内积score x · p这跟线性层的计算几乎一样。但直接使用内积会有一个问题特征向量的模长会影响分数大小。模长大的特征天然更容易得到高分模型可能学会“把特征模长变大来讨好分类器”而不是真正跟原型方向靠近。所以我更推荐使用余弦相似度先对特征和原型分别做 L2 归一化再算内积这样分数只跟方向相关跟模长无关。余弦相似度取值范围是 [-1, 1]直接作为 logits 时数值范围太小会让 softmax 的梯度变弱导致训练变慢。解决办法是引入一个温度参数 T将相似度除以 T 后再进入交叉熵即score cos_sim / T。温度越小logits 的区分度越大训练越激进温度越大分布越平滑。在实际训练中我习惯把温度初始化为一个较小的值比如 0.05然后作为可学习参数让网络自己调整。也见过有人直接固定 T0.1 也能跑得很稳。这和对比学习里的温度系数本质上是同一件事控制样本对的相似度分布锐利程度。如果你发现训练初期损失降不下去多半是温度设得太大把所有相似度都压扁了。3.2 一个类别多个原型解决类内多模态一个类别有 M 个原型时匹配分数会变成[num_classes, M]的矩阵。我们需要把它聚合成一个类别分数再喂给交叉熵。最常见的聚合方式是logsumexp也就是对 M 个相似度做平滑最大值。公式写出来是log(sum_i exp(s_i))它的特点是当某一个原型匹配度特别高时聚合分数几乎等于最高分但同时又保留了对多个原型都匹配时的累积效应。为什么不用硬 max 或平均硬 max 的梯度只流向得分最高的那个原型其他原型得不到学习信号容易坏死平均则会被大量低分原型拖累导致类别分数被拉低。logsumexp介于两者之间既让高分原型主导又让其他原型有机会获得梯度。用大白话说这是一个“选最有代表性的那个原型但不完全无视其他原型”的软选择操作。多原型的数量 M 是一个核心超参。太小覆盖不住类内多模态太大原型之间可能重叠甚至互相竞争模型会把原型当作记忆样本的工具导致过拟合。我在常见图像分类任务上试下来M 选 2 到 5 是比较稳的区间。具体选多少要看类的多样性和训练数据量。数据量很大、风格差异明显的类别可以适当多给几个原型数据量小或者类别很单纯一个原型也够。3.3 原型的初始化与更新原型初始化的方式对收敛影响非常明显。我试过三种随机初始化、类别均值初始化、用预训练 softmax 分类头参数初始化。随机初始化最省事但训练前期损失会剧烈震荡因为随机向量跟真实特征分布相距太远模型需要很多轮才能把原型拉到有效区域。类别均值初始化是我最喜欢的方式。方法很简单先用现有特征提取器对训练集做一次前向收集每个类别所有样本的特征向量按类求平均然后把均值向量作为该类原型的初始值。如果多原型就给均值向量加一些小噪声避免所有原型完全重合。这个操作能极大缩短冷启动阶段。软标签分类头参数初始化也有一定效果但等价于从一个已经偏斜的边界出发不如直接以数据分布为中心。训练开始后原型和骨干网络一起走梯度下降优化器。但要注意原型是参数它们是参与前向计算并接收梯度的。如果想加强原型之间的分野可以在损失函数里增加一项“原型间距正则”鼓励不同类别的原型互相远离。更简单的做法是在每次参数更新后对原型做 L2 归一化保证它们落在单位超球面上这样可以维持数值稳定性。3.4 “卷积”到底体现在哪里有人会问前面讲的都是向量计算和卷积有什么关系原因在于特征不总是全局向量而是空间特征图。假设骨干网络输出的特征是[B, C, H, W]我们需要对空间位置进行信息聚合。传统做法是先做全局平均池化得到[B, C]再进线性层。CPL 想保留空间信息可以选择不先池化而是把每个原型当作一个1×1卷积核直接在特征图上做卷积。每个类别 M 个原型就是num_classes * M个1×1卷积核。卷积输出的响应图[B, num_classes * M, H, W]中每个空间位置上的值代表该位置的原型匹配程度。接下来可以对整个响应图做平均池化或最大池化得到该原型对整张图的最终匹配分数。这个流程就是“卷积原型”的由来。这种设计有一个额外的好处响应图本身可以作为注意力图或可解释性工具。训练完成后你可以把某个原型的响应图上采样回原图大小看看这个原型到底关注了图像的哪个区域。我在一个细粒度分类任务里观察过猫的某个原型确实会在眼睛周围产生高响应另一个原型会在毛发纹理区域产生高响应。这意味着 CPL 不光是提升了分类还顺便给了你一个“模型到底看哪里”的窗口。4. 工程落地我用 PyTorch 复现 CPL 的完整方案4.1 最小可运行代码实现聊了这么多理论直接来看看代码。下面是一个最小可用的原型分类层输入是骨干网络输出的特征向量输出是各类别分数。import torch import torch.nn as nn import torch.nn.functional as F class PrototypeLayer(nn.Module): def __init__(self, num_classes, feat_dim, num_prototypes3, temperature0.05): super().__init__() self.num_classes num_classes self.num_prototypes num_prototypes # 形状: [num_classes * num_prototypes, feat_dim] self.prototypes nn.Parameter( torch.randn(num_classes * num_prototypes, feat_dim) ) self.temperature nn.Parameter(torch.tensor(temperature)) def forward(self, x): # x: [B, feat_dim] # 归一化特征和原型计算余弦相似度 x_norm F.normalize(x, dim-1) p_norm F.normalize(self.prototypes, dim-1) # [B, num_classes * num_prototypes] scores torch.mm(x_norm, p_norm.t()) / self.temperature # 拆分成 [B, num_classes, num_prototypes] scores scores.view(-1, self.num_classes, self.num_prototypes) # 用 logsumexp 做软选择聚合 scores torch.logsumexp(scores, dim-1) # [B, num_classes] return scores这段代码可以直接替换掉模型最后的nn.Linear(num_classes)。假设你原来的模型是这样构造的model nn.Sequential( backbone, nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(feat_dim, num_classes) )改成model nn.Sequential( backbone, nn.AdaptiveAvgPool2d(1), nn.Flatten(), PrototypeLayer(num_classes, feat_dim, num_prototypes3) )损失函数完全不用动继续用nn.CrossEntropyLoss()。优化器一般用SGD或AdamW区别不大。我建议对原型参数单独设置一个稍小的学习率因为原型的梯度比全连接层更平滑学习率太大会导致原型在超球面上来回跳动。4.2 训练策略从 Softmax 到 CPL 的平滑迁移如果你已经有一个训练好的 softmax 模型不建议直接把分类头换成随机初始化的原型然后从头训。更稳的做法是分两阶段。第一阶段加载预训练 softmax 模型的骨干权重冻结或者以非常低的学习率训练只初始化原型。初始化方法就是把训练集特征按类求平均得到每类第一个原型。类别有多个原型时在均值附近添加小噪声。这个阶段跑 10 个 epoch 左右让原型先落到合理区域。第二阶段取消冻结让骨干网络和原型一起更新。此时可以适当降低分类头学习率通常设为主干学习率的 0.1 倍避免原型被新特征“反复拉拽”导致震荡。整个过程依然使用标准交叉熵不需要额外修改损失。如果是从零训练也可以直接随机初始化原型然后用热身策略前几个 epoch 只训练原型让特征提取器保持预训练不动之后再联合微调。这样做能明显减少训练初期的损失爆降和回升现象。我用这个策略迁移过三个任务最终指标都比直接端到端同时训练好约 1%。4.3 关键超参数调节心得第一个超参是原型数量num_prototypes。它和类别数、数据规模、类内多样性相关。我通常先在验证集上做一个 1、2、3、4、5 的小网格搜索。若某个类内部明显有多个视觉簇就增加数量。如果从 3 增加到 4 时验证集提升不再明显就停止。多原型的收益会边际递减而且会开始消耗内存。第二个超参是温度 T。温度在损失和梯度中处于一个微妙位置。T 太大logits 变小softmax 分布变得均匀梯度较弱T 太小logits 变大训练早期容易导致原型快速朝着某些离群点过拟合。我的经验是 T 范围可以从 0.01 到 0.2 之间调。更省心的方法是把温度做成可学习参数同时给它一个 L2 正则或限制范围。对比学习里常用的温度是 0.07 左右可以参考但不要照抄毕竟任务分布不同。第三个超参是“是否对特征做归一化”。我建议默认开启余弦相似度也就是对特征也做 L2 归一化。如果要保留特征模长信息可以把特征归一化去掉但那样类中心会受样本数量不平衡影响。类别样本数差异大时大类的特征模长往往更大不加归一化会让原型被大类主导。少数类分类任务建议强调归一化。4.4 损失函数可以再加些什么交叉熵是保底额外加辅助损失往往能进一步提升鲁棒性。最常用的是原型间距损失我们希望不同类别的原型能够尽量分开。公式可以写成max(0, margin - ||p_i - p_j||)或者反过来的余弦相似度损失。实现时随机采样一些原型对惩罚彼此相似度超过阈值的对。另一个有用的是“样本-原型一致性损失”。在交叉熵之外鼓励样本与其真实类内最接近原型的相似度至少高于与最接近的其他类原型的相似度一个 margin。这其实是度量学习里的三元组损失变体。加上它之后特征空间的类内紧凑性会更强抗噪声能力也会改善。如果训练数据本身存在大量噪声标签还可以配合标签平滑或者鲁棒损失。CPL 的优点是原型往往代表“大多数样本”的方向个别错标的样本对原型的引力有限但错标比例高时仍然会被带偏。我建议先用干净验证集评估噪声比例再决定是否引入GCE或Symmetric CE这类损失。原则上CPL 不会放大噪声但也不能完全免疫。5. 对比实验泛化性能、抗噪能力与分布外感知5.1 常规精度与校准指标对比我在一个公开图像分类数据集的子集上做了复现主干网络保持完全一致只替换分类头。表里列出的数字来自我个人实验记录不代表任何论文结果但趋势是稳定的。分类头准确率ECE越低越好训练收敛轮数SoftmaxCE83.2%0.08745CPLM184.1%0.06450CPLM384.9%0.05558CPLM3辅助损失85.3%0.04965准确率提升不算夸张但 ECE期望校准误差下降非常明显。这说明 CPL 给出的置信度与真实正确率更匹配不会出现“错得自信”的情况。对需要概率输出的业务系统这一点比准确率还重要。另外CPL 的收敛速度略慢于 softmax因为原型初始化后需要更多轮次微调换来的收益在后期逐渐体现。5.2 标签噪声下的鲁棒性我在相同数据集上做了两组损坏测试随机翻转 20% 训练标签以及按类别混淆方式注入噪声。Softmax 基线在干净测试集上的准确率下降了 4.7 个点而 CPLM3只下降了 2.9 个点。噪声比例提高到 40% 时差距更明显。原因是多原型像是“投票委员会”。错误标签的样本特征如果与正确类原型差异较大它对正确原型的影响会被其他正常样本稀释。而 softmax 层通过反向传播调整权重时每个样本都会直接推动决策边界错误样本对边界的扭曲非常直接。换句话说CPL 的决策基础是“与中心原型的匹配度”天然具有滤波作用。当然噪声比例超过 50% 后谁也不好使需要先做标签清洗。5.3 分布外样本带来的风险信号我把测试集中的一部分替换为来源完全不同的图像观察模型在“未知样本”上的表现。Softmax 对其中 60% 的未知样本给出了超过 0.9 的置信度CPL 的同一数值大约为 35%明显更保守。不过要说清楚CPL 的 softmax 输出仍然是归一化概率总分恒为 1所以只看概率分布依然有被误导的可能。真正有价值的是查看“与最近原型的原始相似度”。推理时额外计算max_k cos(feature, prototype_k)如果这个值低于某个阈值我们就把样本标记为未知。所以 CPL 天然支持设置拒绝阈值这是传统 softmax 很难做好的事。阈值怎么定我一般把训练集上所有正确分类样本的最小相似度作为下界再用验证集标定。业务上可以先把阈值放宽尽可能召回未知样本避免误拒正常样本。5.4 可解释性上的意外收获当使用保留空间响应图的卷积原型版本时模型会在原图上给出每个原型的响应热区。这相当于把“为什么被判为这一类”变成了“这一类里的哪些子模式被匹配到了”。我在一个工业质检模拟任务里观察到某些原型聚焦于纹理缺陷某些原型聚焦于边缘形状分类结果能对应到具体视觉特征。这种能力对审计和调试很有价值。6. 踩坑记录与排查技巧实录6.1 原型数量多了不收敛我一开始贪心把每个类都设置了 8 个原型结果验证集准确率反而掉了 1 到 2 个点。原因在于原型数量超过了类内真实的子簇数量部分原型会退化成为少数样本的“记忆单元”甚至出现两个原型完全重合。解决办法是给原型增加 L2 归一化和散布正则或者直接在验证集上做数量搜索。正常数据里每类 2 到 4 个原型通常就够用。6.2 特征归一化引发的梯度问题对特征做 L2 归一化之后整个网络的可学习参数变小梯度信号变弱有时会出现损失下降缓慢、指标上不去的情况。这种时候先检查温度如果温度是固定值试着调小到 0.05 以下如果是可学习温度看它是否学会了一个很小的数值。另外可以把骨干网络最后的 BN 层去掉或替换为 LayerNorm因为 L2 归一化之后已经自带尺度不变性再叠加 BN 反而可能造成统计量抖动。6.3 内存占用和推理速度的平衡原型层的参数数量很少但如果你真的把原型当作卷积核在高分辨率特征图上滑动计算内存会成倍增加。我的建议是特征图尺寸控制在 7×7 以下再做原型卷积否则先池化到 1×1。实际部署时可以把原型卷积展开成普通矩阵乘法计算复杂度完全一样。推理速度上CPL 并不会比 softmax 慢多少瓶颈始终在骨干网络。6.4 少数类和空白类的坑类别不均衡时样本数量少的类别原型在训练初始阶段很难得到有效的类均值初始化。解决办法是先做类别均衡采样或者在初始化阶段使用更强的数据增强。另一个坑是某些类在特征空间里距离很近原型之间容易重叠。这时候可以引入一个“正交性约束”鼓励所有原型彼此不相关。它可以算作一种正则项让模型学到更分散的原型。7. CPL 适用边界与后续扩展7.1 什么任务适合换什么任务不适合CPL 特别适合以下几类任务类别数量中等几十到几千、类别内部存在多种形态、数据存在一定噪声、需要置信度校准或未知类拒绝。反之如果类别数量极少比如二分类、类别特征高度相似到只有细微差异或者你只需要一个在干净集上精度尽可能高的模型softmax 仍然是很强的基线。CPL 不是银弹它是一个值得放进工具箱的备选方案。7.2 向对比学习与增量学习方向扩展原型本身可以当成“类级别的正样本中心”来使用。如果你把 CPL 和对比学习结合可以让样本特征同时向原型靠拢并与其他样本的负原型保持距离。增量学习里原型的另一个好处是记忆容量小可以保留旧类原型参与新类训练缓解灾难性遗忘。这些方向我都做过初步实验后续有机会再单独写一篇。7.3 一点个人建议如果你也想替换 softmax我的建议是先不要大动干戈只把最后分类层换成原型匹配层用类均值初始化训练几个 epoch 观察特征分布。你会很快感受到置信度分布变得更自然模型对错误的“笃定感”明显下降。等你熟悉了原型机制再慢慢调原型数量、温度和辅助损失。Softmax 本身没有错只是在面对更复杂、更多样的真实数据时原型式的分类器多给了我们一个选择。