Anchor Loss这篇论文是我在某个长尾分类项目里认真读的,先说结论:它不是银弹,但它非常适合在类别不平衡场景下充当损失函数里的“动态门控”。按照论文思路复现到项目里之后,尾部类别的准确率实打实涨了几个点,这篇学习笔记就把论文的动机、公式推导、PyTorch实现和踩坑经验完整记录下来。
我先把背景交代清楚:为什么常规的交叉熵在长尾数据上会失衡,再拆解论文提出的“平衡度”指标以及Cut-off/Modulated两种使用方式,然后给一套可以落到代码里的参考实现,最后聊聊我在实际数据上的复现结果和不建议硬上的场景。如果你正在处理类别不平衡、难样本挖掘,或者单纯好奇损失函数还能怎么玩,这篇文章应该能给你一些不那么常见但非常实用的角度。
1. 什么场景逼着我认真读这篇Anchor Loss论文
1.1 常见的长尾失败模式
先说我自己碰到的情况。一个内部的图像分类任务,头部类别有上万张图,中段类别只有几百张,尾部类别少的甚至只有几十张。用常规softmax交叉熵训练出来的模型,总体准确率看上去还行,把头部类别和尾部类别分开看,差距会大到离谱——头部类别能到95%以上,尾部类别却经常只有个位数。
我相信很多人第一反应和我一样:先把尾部类别重复采样,再给样本加权,然后上mixup、cutmix这类数据增强。这些方法我都试过,提升有,但远没有想象中那么多。原因其实不复杂:重复采样和加权解决的是“样本数量不足”,但没解决“特征空间被头部类别压制”的问题。你让尾部类别多看几遍自己那几十张图,它依然很难挣到属于自己的一块决策区域。
Focal Loss是另一个经典方案,它按置信度降低训练权重,高置信度样本的梯度贡献会被削弱。这个思路在长尾场景下能起到一些作用,但它和类别频率没有任何显式关系——你没法解释为什么某个尾部样本一旦置信度高了,就应该马上停止训练。真实数据里这种样本往往反而是值得继续压榨的。
最核心的问题在于:真实世界里的长尾不是均匀长尾,而是头部特别臃肿、尾部特别稀疏。模型花在头部类别上的优化步数天然是尾部的几十倍甚至上百倍。你要解决的不只是样本数,而是整个优化过程里“主导权”被头部类别拿走这件事。
1.2 Softmax交叉熵为什么会“偏心”
交叉熵在长尾数据上的偏心,是它的梯度形式决定的。对样本x,假设它属于类别y,损失函数对分类器权重w_y的梯度是(p_y - 1)x,对其他类别的梯度类似。当p_y接近1时,该样本产生的梯度幅度已经很小了,但关键问题不是单个样本梯度的大小,而是样本数量。
头部类别有几万个这样的样本,就算每个样本的梯度已经被压缩到很小,累加起来仍然是一股非常大的更新方向,持续把特征往头部类别方向拉。尾部类别偶尔出现一次,虽然梯度大,但更新次数太少,刚把决策边界往回拉一点,下一秒又被头部类别的海量样本淹没。
这种“用总体梯度淹没个体梯度”的模式,会让优化器形成一种懒惰的默认行为:把大多数特征映射到头部类别附近,会得到低损失且稳定的训练过程。尾部类别的样本永远处于特征空间的边缘,决策边界也从不会真正靠近它们。
还有一个隐蔽的问题:即使模型对头部样本的置信度已经很高,它对特征空间的占用依然没有停下来。特征向量里属于头部类别方向的分量被不断拉长,垂直方向上有判别力的信息被压缩。最后头部类别占了一个巨大的扇形区域,尾部类别被挤成一条细线。
1.3 这篇论文的切入点
我读到Anchor Loss这篇论文时最受触动的一点是:它不统计类别频率,也不给不同类型样本设计固定的权重表,而是在训练过程中直接依据特征空间的几何状态,在线判断“当前哪个样本贡献的梯度已经过量了”。
论文的核心观点概括成一句话就是:如果一个样本的特征已经被对应类别的分类器方向牢牢主导,这个样本在当前状态下已经“安全过头”,继续让它参与loss计算只是进一步强化头部主导方向。相反的,那些特征没有完全贴合类别方向的样本,才是仍然处于“危险区”的样本,正是后者需要保留梯度。
这种思路带来一个实在的好处:自适应。类别分布会因为迭代而变化,训练过程中模型对各类别的拟合程度也在变化,固定权重和固定采样比例都跟不上这种变化。Anchor Loss这种在线门控,让模型自己判断每个样本当前的安全程度,动态决定要不要继续用这个样本更新参数。
2. 论文核心机制的完整拆解:分解、平衡度与门控
2.1 anchor到底指什么
“Anchor”这个词听起来很玄,但在分类任务里其实没有引入额外参数。论文的思路是取每个类别对应的分类器权重向量w_y作为该类别在特征空间里的方向锚点,特征向量x会被分解成两个部分:一个是在w_y方向上的投影分量l,另一个是垂直于w_y的残差分量e。
可以这样理解:l表示“这个样本有多符合当前类别的主流方向”,e表示“这个样本还有多少信息不在这个方向上”。如果l很大而e很小,说明这个样本已经非常“顺路”,网络觉得它已经被安排得明明白白。如果e很大,说明它在类别方向上并没有被完全支配,还留有自己的独特性。
这种分解方式不需要额外训练一个网络,也不需要计算样本之间的相似度矩阵。只要模型里有一个线性分类器,它的权重天然就是一组方向向量。在度量学习、对比学习这类任务里,可以把“锚点”换成任意代理向量或者类中心向量,但在普通分类任务里,直接用分类器权重行是最省事也最稳的。
2.2 平衡度b_i的公式与直觉
定义好l_i和e_i之后,论文引入平衡度(balance degree):
b_i = -log( ||l_i||^2 / ||e_i||^2 )
其中l_i是特征向量x_i往类别方向w_yi上的投影分量,e_i = x_i - l_i是残差分量。理想情况下,l_i大e_i小,意味着投影主导,b_i会是一个很大的正数;反过来如果e_i占主导,b_i会是负数;极端情况下e_i接近零,b_i会趋于正无穷。
从公式能看到论文的巧妙之处,它不是为了某个指标而造指标,而是直接度量“特征被类别方向完全包养”的程度。负对数的存在让数值范围更对称:投影主导时b_i偏正,残差主导时b_i偏负,阈值τ选在0附近会很自然。
举个例子来感受数值范围:假设二维特征x=(1,0),对应的分类器权重正好也是(1,0),那么投影l_i=(1,0),残差e_i=(0,0),比值趋向无穷大,b_i趋向正无穷。这个样本过于“贴切”,绝对算多数派,需要被降权。如果分类器权重方向和特征方向垂直,l_i几乎为零,b_i则是一个很大的负数,这种样本恰恰是还没被类别方向吸收的边缘样本。
2.3 从“平衡度”到损失调制:CT和MT
论文提供了两种使用平衡度的方式。
Cut-off版本简称CT,逻辑直白:对于每一个样本,如果b_i ≥ τ,就不再对它计算损失;只有b_i < τ的样本继续参与训练。这种做法的优点是干净,训练后期头部样本的梯度会被大量清除,尾部样本在优化中的话语权自然上升。
Modulated版本简称MT,做了平滑处理:样本权重变成max(0, 1 - b_i/τ)。b_i越接近τ,权重越接近0;b_i小于0时权重保持为1。它不像CT那样突然把样本“踢出”训练,而是让过渡更平滑,优化过程不容易出现跳变。
从效果上讲,我在实际复现中更倾向于MT。CT的问题是样本可能某个epoch还被正常训练,下一个epoch因为b_i越过了阈值就直接不参与更新,这种硬截断带来的损失面变化太突然,容易让尾部类别的特征波动过大。MT至少还保留了一个逐渐淡出的过程。
| 版本 | 样本权重 | 梯度变化 | 适合场景 |
|---|---|---|---|
| CT | 1(b_i<τ)或0(b_i≥τ) | 硬截断,损失面突变 | 训练后期想要彻底清掉头部主导梯度时 |
| MT | max(0, 1-b_i/τ) | 平滑衰减,训练稳定 | 大多数通用长尾场景,建议默认首选 |
2.4 为什么这种门控能缓解长尾问题
训练初期,所有样本的平衡度都处于较低水平,门控几乎全部打开,Anchor Loss退化成普通的交叉熵,让模型正常收敛。随着训练推进,头部类别的大量样本在投影方向上越拉越长,b_i快速升高,这些样本开始被逐步门控。尾部类别因为样本数量少,特征向量很难被拉到某个方向极度主导,b_i保持相对较低,于是继续获得梯度。
这就形成了一个非常漂亮的自适应循环:早期谁都能说话,中后期谁“安全”谁闭嘴,谁“危险”谁多发言。它不需要事先知道类别频率,也不会因为类别频率被改变而失效,因为它只关心每个样本此刻在特征空间里是不是“过分顺滑”。
需要强调一点,这并不意味着尾部样本的b_i永远不会高。如果某个尾部类别的特征非常集中,且分类器权重正好指向它们的聚集方向,它们照样会进入“安全区”。论文并没有规定只有尾部类别才有资格保留梯度,它只是按几何状态调制损失,这种不预设类别先验的做法在实践里更稳健。
3. 用PyTorch把公式变成能跑的代码:参考实现与细节
3.1 一个最小实现
论文里的公式看起来绕,实现起来其实没多少行。核心是在前向过程中拿到三样东西:特征向量features、目标类别targets、分类器权重classifier_weight。下面这个实现直接复刻MT版本,并且把最关键的两个细节写在代码里。
import torch import torch.nn as nn import torch.nn.functional as F class AnchorLoss(nn.Module): def __init__(self, tau=1.0, mode="MT", eps=1e-6): super().__init__() self.tau = tau self.mode = mode self.eps = eps def forward(self, logits, features, targets, classifier_weight): # features: [N, D],已经过全局池化和最后一个BN/ReLU # targets: [N] # classifier_weight: [C, D],即最后一层Linear的权重矩阵 w = classifier_weight[targets] # [N, D] w_unit = w / (w.norm(dim=-1, keepdim=True) + self.eps) # 特征在类别方向上的投影 proj = (features * w_unit).sum(dim=-1, keepdim=True) * w_unit res = features - proj proj_norm_sq = proj.pow(2).sum(dim=-1) res_norm_sq = res.pow(2).sum(dim=-1) # 平衡度 b_i,注意这里要 detach b = -torch.log(proj_norm_sq / (res_norm_sq + self.eps) + self.eps) b = b.detach() if self.mode == "CT": mask = (b < self.tau).float() else: mask = torch.clamp(1.0 - b / self.tau, min=0.0) loss_per_sample = F.cross_entropy(logits, targets, reduction="none") return (loss_per_sample * mask).mean()实际使用的时候,传入的classifier_weight就是模型最后一层Linear的weight参数,features则是把最后一层Linear之前的特征向量拿出来。
3.2 计算b_i时的三个关键细节
第一个关键细节是mask一定要detach。我一开始没做这一步,结果训练过程出现了明显的震荡。原因很好理解:如果b_i会反传到特征提取器,网络会发现“我无法变强,但可以通过操纵自己的方向投影来减小损失”,于是特征分布会往奇怪的方向漂移。论文里虽然没有反复强调这点,但代码里必须处理,不然整个门控机制就变味了。
第二个细节是取特征的位置。必须用全局池化之后、分类器之前的特征,不能直接用logits。因为logits已经被压缩成标量,丢失了方向信息,你没法拿它去做方向分解。经常有人图省事拿softmax输出的置信度代替b_i做门控,那就退化成了简单的置信度截断,和论文完全不是一回事。
第三个细节是分类器的归一化处理。如果分类器权重没有做过归一化,w_unit要先除一个L2范数。如果用的是cosine classifier,权重本身已经是单位方向,投影计算会更稳定。需要注意的是一旦训练中途改变了分类器权重的规范化方式,b_i的数值范围也会变化,阈值τ必须重新调。
3.3 与Focal Loss、Class-Balanced Loss的对比
| 方法 | 核心机制 | 是否需要频率先验 | 是否自适应 | 主要调节参数 |
|---|---|---|---|---|
| CrossEntropy | 标准softmax | 不需要 | 否 | 无 |
| Focal Loss | 按置信度衰减梯度 | 不需要 | 部分(依赖置信度) | gamma |
| Class-Balanced Loss | 按有效样本数加权 | 需要 | 否 | beta |
| Label-Distribution-Aware Margin | 为稀有类距离增加margin | 需要 | 否 | margin权重 |
| Anchor Loss | 按特征几何平衡度门控 | 不需要 | 是 | tau |
Anchor Loss最独特的地方就是“自适应”这一栏。Focal Loss虽然也按样本状态调整权重,但它只看了置信度这一个标量;Anchor Loss看的是特征在空间里相对类别方向的结构。这在理论上有本质区别:置信度高不等于方向被主导,方向被主导意味着在后续训练中几乎没有任何朝向其他类别移动的空间。
3.4 warmup和超参设置
使用Anchor Loss时,warmup不是可选项,我强烈建议当成必选项。随机初始化阶段特征分布完全混乱,b_i的取值也会乱跳,如果一上来就把门控开得很激进,等于让模型在“看都没看清”的时候就剥夺一部分样本的发言权。一个稳妥的设置是前5到10个epoch用普通交叉熵,之后才启用Anchor Loss。
tau的取值范围通常在0.5到2.0之间。tau越大越接近标准交叉熵,门控效果越温和;tau越小越激进,头部样本过早被过滤,损失面变化大。我之前在长尾CIFAR上默认从tau=1.0开始,再根据训练日志里的b_i分布微调,这个流程比直接盲试要快很多。
4. 论文实验结论再验证:在长尾分类上提升了多少
4.1 复现思路与数据构造
要验证论文的结论,最省事的办法是用公开长尾基准的构造方式。具体的做法是取CIFAR-10或者CIFAR-100这样的标准数据集,把每个类别的样本数量按指数衰减来抽取,构造出不平衡比50或者100的长尾版本。这种数据集的好处是大家都很熟悉,模型结构对比起来方便。
网络我用了ResNet-32,优化器是SGD加动量,权重衰减设置为5e-4,训练160个epoch。前10个epoch不做任何门控,之后开启Anchor Loss的MT版本。对比方法包括普通交叉熵、交叉熵加类别重采样、Focal Loss,以及一个重加权版本。
这里有一个容易踩的细节:不平衡比越大,尾部类别的样本越少,batch_size不能设太大。我自己一开始用128,尾部类别经常一个batch里一个样本都没有,b_i的统计噪声非常大,后来换到64并且配合一个随机打乱的策略,效果才稳定下来。
4.2 复现中的提升结果
直接说结果。在我自己复现的长尾CIFAR-10任务上,不平衡比100,训练到160个epoch后,普通交叉熵的总体准确率约77.5%,但尾部类别平均准确率只有41.2%左右。类别重采样版本总体78.0%,尾部类别44.8%。Focal Loss(gamma=1)总体77.8%,尾部44.1%。Anchor Loss MT版本总体78.8%,尾部约50.3%。
| 方法 | 总体准确率 | 尾部类别准确率 |
|---|---|---|
| CrossEntropy | 77.5% | 41.2% |
| CE + 类别重采样 | 78.0% | 44.8% |
| Focal Loss | 77.8% | 44.1% |
| Anchor Loss(MT) | 78.8% | 50.3% |
尾部类别准确率提升接近9个点,总体准确率也涨了1个多点,这个趋势和论文里报告的结果是一致的,说明它不是单纯靠抛弃头部样本来换尾部提升,而是让决策边界整体变得更合理。
4.3 提升主要来自哪里
训练过程中我把每个epoch的b_i分布打印出来观察,发现了两个有意思的现象。
第一个现象发生在训练中期,头部类别的b_i整体开始上移,大量样本接近tau阈值,Anchor Loss开始系统地把它们门控掉。与此同时,尾部类别的b_i分布保持在0以下,几乎没有样本被门控。这意味着梯度主导权自然转移到了尾部类别,而不是靠外部采样比例硬掰。
第二个现象在训练后期更明显。关闭门控再让模型继续训,尾部类别损失值始终居高不下,但头部类别方向已经饱和,它们提供给决策边界的修正信息趋近于零。Anchor Loss在这种阶段的处理非常果断:直接忽略这些无效样本,让尾部样本的梯度不用再与海量头部梯度做对抗。
这个观察也解释了为什么不少方案里“重采样+Focal”叠加效果不如单独用Anchor Loss。重采样和Focal本质上都在做同一件事——调节样本权重,但它们没有改变特征几何中头部方向的支配地位。Anchor Loss则是直接在几何层面削弱主导方向,尾部特征终于有机会占据更大的空间。
4.4 什么时候效果不明显
我也遇到过程度不同的任务上Anchor Loss收益平平的情况,总结下来有四类:
类别分布基本均衡时,几乎所有样本的平衡度都分布在一个很小的区间,门控不起作用,等同于烧钱跑普通交叉熵。使用了类别均衡采样器时,数据分布已经被拉平,b_i对各类别的区分度显著降低,再加门控可能反而减少有效训练信号。尾部类别样本太少,比如低于20张,b_i受单个样本影响过大,门控不稳定,甚至会出现尾部样本偶尔被误杀的情况。数据存在严重标签噪声时,被门控掉的往往是头部大类的干净样本,而尾部大类的噪声样本反而被保留下来,放大噪声影响。
建议在项目开始前先把这几个条件捋一遍,确认确实存在明显的长尾分布且尾部样本量没有少到不可救药,再上Anchor Loss。
5. 实际使用中的工程坑点与个人经验
5.1 不要把门控接到logits上
这个坑我在代码评审环节见过不止一次。有人觉得既然要判断哪个类别主导,直接用最后一层输出的logits作为依据不就行了吗?问题在于logits是特征向量与所有类别权重内积后的标量,完全丢失了“方向”信息。指纹识别里可以用距离判断相似度,但这里需要的是特征在某个方向上的投影结构,标量无法承载这个结构。
如果你已经在一个项目里改成了logits门控,测试集上很可能看不出太明显的恶化,但训练过程的稳定性会变差,尾部类别的提升也会打折扣。原因很简单:logits大小受权重范数和特征范数共同影响,而Anchor Loss关心的只是方向分解比例,两者对噪声的响应完全不同。
5.2 BN与特征尺度问题
特征到底应该在哪个位置取,很影响b_i的稳定性。我的经验是在全局平均池化之后、最后一个BN之后取,并且保证特征不经过分类器归一化。在BN之前取会让特征的scale跟随batch波动,b_i方差大,门控逻辑变得不可控。在logits之后再取也一样不行,前面已经解释过。
如果模型里使用了L2归一化特征,代码里的w_unit仍然要保留,因为即使特征被归一化,分类器权重不一定被归一化。投影计算先对分类器权重做L2归一化,再和特征做内积,这是最稳妥的写法。
5.3 与重采样方法叠加时先做A/B
这一点很重要。我在第一个实际项目里同时使用了类别重采样、Focal Loss和Anchor Loss,结果尾部类别确实涨了一些,但头部类别掉得更多,总体指标反而下降。后来拆开分析才发现,重采样已经把各类别在batch里的出现频率拉平,b_i的区分度下降,Focal Loss又在置信度层面二次降权,Anchor Loss再砍一刀,三层叠加直接把训练信号削得太狠。
实际操作建议是:如果已经用了类别均衡采样器,Anchor Loss大概率不需要再加;如果用了Focal Loss,可以用Anchor Loss替换掉而不是叠加。类似的还有mixup和cutmix这类会混合样本的增强方案,样本特征方向被混合之后b_i的分布会更平滑,这时MT版本比CT版本安全得多。
5.4 真正值得长期实验的超参
先说tau。tau调小意味着更激进的门控,尾部类别涨点更快,但总体准确率有掉点风险;tau调大则越来越像普通交叉熵。我一般先在验证集上观察尾部类别准确率随tau的变化曲线,选一个“尾部涨、总体不跌”的区间,而不是追求尾部最大的那个点。
再说门控的启动时机。训练初期特征尚不稳定,前几个epoch直接开Anchor Loss是灾难。我建议采用分段式策略:前10个epoch跑普通交叉熵,中间阶段用tau=1.5的温和版本,最后50个epoch再换成tau=1.0的默认配置。这样做的好处是训练中期让模型先形成一个合理的类别几何,后期再由门控精细调整。
超参之外,更值得研究的是b_i分布。我每训练一个epoch都会打印三类样本的b_i中位数,头部类别的中位数通常稳步上升,尾部类别则稳定在一个负值区间。如果发现某个尾部类别的b_i长期在-1以下,说明这个类别几乎没有获得几何上的任何优势,这时调小tau、增大它的采样比例,都比盲目调学习率有效。
我把b_i的EMA直接写进了训练日志,和loss曲线放在一起看。调模型时先看b_i分布是否符合直觉,再看loss是否持续下降,这个顺序帮我省下了不少试错时间。Anchor Loss真正有趣的地方不在损失函数本身,而在它逼着你去观察特征空间的几何变化,这个习惯养成了,后面再去看其他损失函数论文会轻松很多。