Anchor Loss 这个名字,第一眼很容易让人以为是三元组里的 anchor。实际上它跟 anchor 样本没有直接关系,它的意思是:每个样本的 loss 尺度,由它自己的“语义困难度”来锚定。说人话就是把 Proxy-NCA 那个全局统一的 temperature,从固定值改成样本自适应的动态值——越靠近其他类别的样本,训练时被推得越用力。这篇文章我想把它从动机、公式、代码到复现坑,完整捋一遍。如果你正在做检索、识别的特征学习,或者在调分类损失的 scale/temperature,这篇值得花十分钟看完。
1. 它到底改了什么:从 Proxy-NCA 说起
1.1 为什么深度度量学习需要 proxy
做图像检索、行人重识别、人脸验证、商品推荐这类任务,底层都是同一个问题:学一个特征空间,让同类近、异类远。早期主流是 contrastive loss 和 triplet loss。triplet 的问题很具体:正负样本的配对数量是组合级的,必须做 mining;mining 策略一多,参数就爆炸——用什么距离、用什么 margin、选半难还是最难,每个决定都影响最终效果;而且一次只更新三条样本,梯度稀疏,训练效率低。
Proxy 类方法换了个思路:每个类别学一个“代表性向量”p_c,叫作 proxy。样本不再跟其他样本直接比,而是跟“类代表”比。这样把样本两两比较的 O(N²) 复杂度,降成了样本-代理比较的 O(N·K),训练量大幅下降,也不需要 mining。Proxy-NCA 是这里面最经典的一个,形式就是一个带温度系数的 softmax 分类损失。它是我做检索任务时常用的 baseline,也是很多论文对比的起点,理解成本低,实现简单,训练稳定。
1.2 Proxy-NCA 的损失形式与固定尺度
Proxy-NCA 的公式长这样:
L(x_i) = -log( exp(s · cos(f_i, p_y)) / Σ_j exp(s · cos(f_i, p_j)) )
注意两个前提:特征 f_i 和代理 p_j 都会归一化到单位长度,所以 cos 就是内积。s 是全局尺度,它扮演的角色相当于 softmax 的“温度倒数”。这个类比很准:知识蒸馏里温度越高,softmax 分布越平滑;这里 s 越大,分布越尖锐,模型对错分的惩罚越重。s 越小则训练越温和,梯度整体变小。
这个 s 在训练里通常固定成一个常数(常见取值 16、32),或者当成一个可学习参数,但无论哪种,它对所有样本一视同仁。这表面看很公平,实际上一视同仁恰恰是问题所在。
1.3 固定尺度的尴尬
真实数据集里,简单样本和困难样本的梯度需求完全不一样。拿细粒度分类举例,两个外观非常相似的物种,比如“蓝鸲”和“蓝松鸦”,处在它们边界上的样本,需要很大的梯度才能被拉开;而一只背景干净、姿态正面的样本,早就被分对了,给它一个大的梯度反而是破坏性的,容易把已经聚好的类内结构又推散。
全局固定 s,只能取一个折中值。s 调太大,简单样本被过度训练,前期 loss 波动剧烈,收敛也变慢;s 调太小,困难样本的推力不足,边界迟迟分不开。传统解法是难样本挖掘,但那是离散的:选出来了才有梯度,没选出来就完全不更新,训练后期还容易 collapse,margin、挖掘频率又是一堆要调的参数。Anchor Loss 想解决的,就是这个“尺度分配”的问题——让每个样本拥有自己的 s。
2. Anchor Loss 的方法拆解
2.1 语义困难度:怎么衡量一个样本难不难
核心问题变成:怎么定义“难”。论文给了一个很实用的定义:语义困难度。一个样本难,是因为它跟“非目标类别”的代理靠得近,特征语义上跟别的类混淆。所以直接计算 f_i 和所有非目标代理的余弦相似度,只保留正的部分(ReLU 截断),再做 L2 聚合:
v_i = [ReLU(cos(f_i, p_j))]_{j ≠ y_i}
c_i = 1 + (1 / √(K−1)) · ||v_i||₂
这个定义里有几个设计值得展开说。
第一,用所有非目标代理的相似度做聚合,而不是只取最大值。只取 max 的问题在于,一个样本可能同时跟两三个类别都模糊,这种“多处混淆”的信息被 max 丢掉了。L2 范数能把多处混淆都累加进来,更准确反映样本处在模糊区域的密度,而不是只看最危险的那一个邻居。
第二,ReLU 截断很关键。余弦相似度为负,说明样本跟那个代理在超球面上已经处于对侧,完全不构成混淆。如果不截断,L2 范数会被一堆负值拉大,c 的估计就被无关信息污染了。
第三,1/√(K−1) 是为了让 c 的数值落在稳定范围。K 越大,v_i 的维度越高,裸算 L2 范数会随 K 增长,同一种困难程度在不同数据集上算出来的数值差异很大,γ 就不好迁移。除以 √(K−1) 之后,c 的量级大致稳定在 1 附近,方便和基础尺度 γ 配合。
提示:换到自己任务上时,这个分母建议保留。不要因为 c 看着小、调制幅度不明显就删掉它,分母直接决定 γ 能不能跨数据集复用。
2.2 样本自适应的 loss scale
有了 c_i,样本 i 的 loss 尺度就是:
s_i = γ · c_i
把它代进 Proxy-NCA,得到:
L(x_i) = -log( exp(s_i · cos(f_i, p_y)) / Σ_j exp(s_i · cos(f_i, p_j)) )
γ 是全局基础尺度,作用跟原来固定的 s 一样;c_i 是样本自己的乘数。容易样本跟任何非目标代理都不怎么靠近,c_i 约等于 1,尺度回到基础值;困难样本 c_i 大于 1,softmax 更尖锐,梯度更大,被更用力地推离混淆的代理。
在超球面上看更直观:f_i 落在决策边界附近、靠近别的代理,它局部的几何是“拥挤”的,需要一个更尖锐的损失函数把边界顶开;而远离边界的样本,一个宽松的损失函数就够。Anchor Loss 相当于让每个样本自己报告“我在多挤的地方”,然后动态调整自己身上的推力。这个名字里的 Anchor,指的就是尺度被困难度“锚定”住。
2.3 它跟难样本挖掘、Focal Loss 的关系
理解 Anchor Loss 最好的方式,是把它放在两个熟悉的东西中间对比。
难样本挖掘是离散的:按相似度排序、选 Top-K、给被选中的样本大梯度、给没选中的零梯度。Anchor Loss 是它的一种连续松弛——所有样本都参与训练,但困难样本被自动放大梯度,困难程度连续地映射到梯度强度,不需要排序、不需要阈值、不需要 mining 阶段切换。
Focal Loss 思路类似,也是降低简单样本的权重。但 Focal Loss 用的是分类置信度来判困难度,Anchor Loss 用的是“与代理的几何距离”。区别在于,置信度在训练中波动剧烈,而到代理的距离相对平滑;更重要的是,Anchor Loss 的判据有明确的类别语义,你能解释这个样本为什么难——它离哪个类的代理太近了,一眼就能看出来。
这个方法的另一个优点是“便宜”。它不引入任何可学习参数,对 Proxy-NCA 的代码改动就几行,没有额外的采样开销,也没有新增超参数(γ 可以直接复用原来的 s)。这类损失很适合作为默认升级项。
3. 复现要点与核心代码
3.1 训练框架与数据设定
跑深度度量学习,标准玩法是这样:用大规模通用预训练的 ResNet-50 当主干,去掉最后的分类层,换成嵌入层,输出 128 维(跟大部分论文对齐),嵌入向量做 L2 归一化。代理是一组形状 (K, 128) 的可学习向量,训练中也要归一化。优化器我用 Adam,学习率 1e-4 起步,配合 warmup;也有人用 SGD 加 momentum,一样能跑。
训练轮数一般在 60 到 90,batch size 取 32 到 128。评估看 Recall@1,就是检索任务里最常用的“第一张就命中”的比例。公开的细粒度分类、商品检索、服装检索这几个基准里都有大量可复现的数字可以参考,Anchor Loss 论文就是在这些基准上做的对比。
3.2 核心代码:从 Proxy-NCA 到 Anchor Loss
直接看代码,差异非常小。下面这段是完整可用的 PyTorch 风格实现:
import math import torch import torch.nn.functional as F def proxy_nca_loss(features, proxies, labels, scale=16.0): # features: (N, D) 已 L2 归一化 # proxies: (K, D) 已 L2 归一化,requires_grad=True # labels: (N,) 类别索引 sim = features @ proxies.t() # (N, K) 余弦相似度 logits = sim * scale return F.cross_entropy(logits, labels) def anchor_loss(features, proxies, labels, gamma=16.0): K = proxies.size(0) sim = features @ proxies.t() # (N, K) # 1. 生成目标类 mask,并取出非目标相似度 mask = torch.zeros_like(sim) mask.scatter_(1, labels.unsqueeze(1), 1.0) non_target = sim * (1.0 - mask) # 2. ReLU 截断:只保留正向相似度 pos = torch.clamp(non_target, min=0.0) # 3. 语义困难度 c_i c = 1.0 + torch.norm(pos, dim=1) / math.sqrt(K - 1) # 4. 样本级尺度,detach 后乘进 logits scale = (gamma * c).detach().unsqueeze(1) logits = sim * scale return F.cross_entropy(logits, labels)几个关键点逐行说。
mask 那步很容易写错。算 c 的时候必须把目标类相似度抠掉,很多人图省事直接对整行 sim 做 norm,结果把样本跟“自己类别代理”的相似度也算进了困难度。目标类相似度是归属信号,不是混淆信号,留着会让 c 整体偏大,方向完全反了。
clamp 对应 ReLU 截断,前面说过,负相似度代表在超球面对侧,不构成混淆,必须清零。
norm 默认就是 L2 范数,在 dim=1 上算,得到每个样本的向量范数,再除 √(K−1),加 1。
scale 我加了 detach,这是复现时值得认真对待的细节。s_i 本身是 f_i 的函数,理论上梯度也能通过 s_i 回流,但实测下来不 detach 会让训练前期明显抖动:代理还在乱动,c 的噪声很大,把噪声再乘进梯度里属于雪上加霜。detach 之后,c 纯粹充当加权系数,训练稳定性好很多。想看严格数学推导的可以按不 detach 写,工程上推荐 detach。
提示:这里直接把 scale 乘到 logits 上后交给 cross_entropy,是对的。Proxy-NCA 本质上就是带温度的分类损失,分母遍历所有代理,正好对应 softmax 的完整归一化项,不需要再手动写循环。
3.3 训练细节与避坑
第一,特征和代理的归一化是硬前提。这个 loss 的所有几何判断都建立在单位超球面上。忘了归一化,cos 就不再是余弦相似度,c 的计算完全没有意义,训练还容易走向梯度爆炸。
第二,γ 怎么选。直接从原来 Proxy-NCA 的 scale 值开始试,我一般先 16,看 loss 收敛曲线再往 24、32 调。前面说过 √(K−1) 把 c 压到 1 附近,所以 γ 的行为基本沿用了固定尺度的经验,这是这个设计里比较省心的部分。
第三,warmup 很有必要。前几个 epoch 代理还处于随机位置,c 的估计噪声很大。我会先跑 5 个 epoch 左右的学习率 warmup,让代理先大致稳定,再让 Anchor Loss 的调制完全生效。有些复现不 warmup 也能出结果,但曲线前期会难看,最终效果也容易被不稳定的初始化拖累。
第四,batch size 不能太小。代理是全局参数,每个 step 只用一个 batch 去推,K 又大,batch 太小时代理更新噪声大,间接污染 c,128 左右比较稳。
4. 实验效果与“为什么有效”
4.1 论文里的结果
论文在细粒度分类、商品检索、服装检索这几个公开基准上都报了结果,整体趋势一致:在相同主干和嵌入维度的条件下,Anchor Loss 相对 Proxy-NCA 基线普遍有 2 到 4 个百分点的 Recall@1 提升。细粒度数据上提升尤其明显,符合直觉——细粒度任务的“难样本”占比高,问题恰恰出在边界混淆上,正是这个损失最擅长的场景。类别区分度本身就很高的数据集上提升相对小,因为大部分样本本来就分得开,调节空间有限。
值得强调的是,它是纯损失函数层面的改进,模型推理时没有任何额外开销。这一点比很多提升效果类似的采样式方法更干净,生产环境替换成本极低。
4.2 从梯度视角看为什么有效
softmax 这类损失对样本 f_i 的梯度,大致可以写成:
∂L_i / ∂f_i ∝ -s_i · [ (1 - p_y)·∂cos(f_i,p_y)/∂f_i - Σ_{j≠y} p_j·∂cos(f_i,p_j)/∂f_i ]
关键在 s_i 乘在整个梯度前面。也就是说,Anchor Loss 不改变梯度的方向结构,而是对单个样本的梯度整体做缩放。困难样本 s_i 大,它的整个梯度被放大;简单样本被缩小。这比“选哪些样本参与”更平滑,也比“给 logits 加 margin”更直接——它控制的是每次更新的幅度,而不是决策边界的位置。人脸识别里常用的 CosFace、ArcFace 是给 logits 加固定 margin,Anchor Loss 走的是另一条路:让温度随样本变。
另外有一个容易被忽略的长期效应:c_i 不是静态的,训练中特征和代理都在动,一个样本今天难,明天可能变得简单,c 会跟着降下来。这相当于自动的课程学习——模型早期集中力气处理大面积混淆,后期困难样本被逐步消化,尺度也随之回落,训练自然收尾。固定尺度的损失没有这个“自我衰减”机制。
4.3 边界情况与局限
Anchor Loss 不是万能。类别数极端大的场景下,c 的计算要遍历所有代理,比固定尺度多一次矩阵乘和一次 norm,单看计算量略增。另外,如果类别里本身就有大量标错的数据,c 会把噪声样本也当成“困难样本”放大,这时候得先做标签清洗。
还有一点要留意:代理初始化质量会影响 c 的早期估计。如果代理初始化太差,前几个 epoch 的调度基本是无效的。这时候可以先固定尺度跑一段再切 Anchor Loss,或者干脆用一个更强的 warmup 阶段,效果一样。
5. 常见问题与排查实录
5.1 训练直接炸了,loss 变 NaN
大概率是归一化问题。检查主干输出的嵌入有没有做 L2 归一化,代理有没有在每次更新后归一化,logits 乘的尺度是否过大。我的排查顺序是:先去掉 Anchor Loss,只跑 Proxy-NCA,确认 baseline 正常;再逐步加回 c 的计算,定位是哪一步引入的数值问题。这样二分定位比瞎调快得多。
5.2 跑完效果跟 baseline 没区别
常见原因有三个。一是 scale 没真正生效:检查你是在 logits 上乘了 (gamma * c),而不是把 c 乘到 loss 上——乘的位置不同,效果完全不同。二是 gamma 调太小,c 又普遍接近 1,调制幅度被淹没,这种情况可以打印一个 epoch 里 c 的分布,看看它到底有没有区分度。三是计算图问题:如果你不小心把 sim 整体 detach 了,梯度就全断了,loss 变成了常数;正常情况只需要对 scale detach。
5.3 跟 Proxy-Anchor、三元组 anchor 搞混
这个名字确实容易绕。Proxy-Anchor 是另一种代理方法,它在每个类别周围设置多个“anchor”代理,用样本到代理的距离构造损失,这里的 anchor 是“锚点”的意思。Anchor Loss 里的 anchor 指的则是样本的尺度被自己的语义困难度“锚定”,两者机制完全不同。三元组的 anchor 就更没关系了,那是样本在 (anchor, positive, negative) 里的角色。看代码最直接:Anchor Loss 对 Proxy-NCA 只是把固定 scale 换成样本自适应的 scale。
5.4 排查速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| loss 为 NaN | 嵌入或代理未归一化 | 补 L2 归一化,逐项检查 |
| 效果无提升 | gamma 太小或乘错位置 | 打印 c 分布,确认乘在 logits 上 |
| 前期剧烈抖动 | 未 warmup / scale 未 detach | 增加 warmup,对 scale detach |
| 大类别数据集上不如预期 | 类别数 K 影响 c 量级 | 保留 1/√(K−1),重新调 gamma |
| 结果与固定尺度完全相同 | mask 写错,c 恒为 1 | 检查 mask 是否移除目标类 |
我个人复现下来的最大感触是,这个方法把“困难样本”这件事从工程技巧变成了损失函数的一部分。你不再需要维护一套 mining 逻辑,也不用反复试 temperature,几行代码换掉 loss,baseline 就能往前走一截。如果你手头正好有检索、识别或分类任务在用 Proxy-NCA 或者它的变体,我建议先复现这个损失,重点盯着 c 的分布看——它会把你没预料到的边界样本暴露出来,这本身就是很有价值的诊断工具。后续还可以试试把 Anchor Loss 跟 Proxy-Anchor 这类方法组合,或者在普通分类任务里把它当加权 softmax 用,都会有值得做的方向。