做人脸识别的同学对ArcFace应该不陌生,这个损失函数自提出以来几乎是深度人脸识别的标配方案。它最核心的思想是:在softmax的基础上,把类间差异搬到了角度空间里,通过给目标类别的角度加上一个固定的间隔,迫使同一类别的特征聚得更紧、不同类别分得更开。要理解这套机制,得先从特征的表示方式说起,也就是特征归一化这个看似不起眼但非常关键的步骤。

一、为什么需要特征归一化:把特征搬到单位超球面上
在早期的softmax训练中,网络最后一层输出的特征向量长度是不受约束的。这样会带来一个问题:模型可以让某个类别对应的权重向量和特征向量在模长上互相配合,只要内积足够大就能分类正确,而不一定真正学到方向上的判别信息。换句话说,模型可能通过“拉长向量”这种取巧的方式降低损失,特征的方向分布反而不够紧凑。
特征归一化的做法很简单,就是对特征向量除以它的L2范数,把所有特征都投影到单位超球面上。归一化之后,两个特征的内积就等于它们的余弦相似度,类别的区分完全由方向决定,模长这个自由度被直接抹掉了。这一步等价于告诉模型:别想着靠长度作弊,老老实实把不同人的特征指向球面上的不同区域。几何直观上,人脸识别要比较的是两张脸“长得像不像”,这种相似性天然适合用夹角来度量,而不是绝对距离,因为同一张脸在不同光照、姿态下的特征模长本来就可能有波动。
import torch
import torch.nn as nn
import torch.nn.functional as F
class ArcFace(nn.Module):
def __init__(self, embedding_size=512, num_classes=10000, s=64.0, m=0.5):
super(ArcFace, self).__init__()
# s是缩放因子,m是角度间隔(弧度制)
self.s = s
self.m = m
# 分类层的权重,输入维度为embedding_size,输出维度为类别数
self.weight = nn.Parameter(torch.randn(num_classes, embedding_size))
def forward(self, features, labels):
# 特征归一化:除以L2范数,映射到单位超球面
features = F.normalize(features, p=2, dim=1)
# 权重归一化:每个类别的权重向量也归一化
weight = F.normalize(self.weight, p=2, dim=1)
# 计算余弦相似度,cos(theta),shape为[batch, num_classes]
cosine = F.linear(features, weight)
return cosine, labels
可以看到权重也做了归一化。权重归一化后,每个类别在球面上对应一个单位向量,logit完全由特征与权重向量的夹角余弦决定,这让训练更稳定,也避免了某些类别靠大权重“霸占”梯度的情况。实践中权重归一化几乎是必开的选项,不做的话训练前期容易出现类别间不平衡的问题。
二、加性角度间隔的数学原理与损失函数推导
标准softmax的logit是特征与权重的内积,归一化之后就是cos(theta)。ArcFace对这个cos(theta)做了一个很巧妙的修改:对目标类别(也就是样本真实标签对应的类别),把它的角度theta加上一个间隔m,变成theta + m,然后再取余弦,其他类别保持不变。写成公式就是:
def arcface_loss(cosine, labels, s=64.0, m=0.5):
# cos(m)和sin(m)是常数,m通常取0.5,对应约28.6度
cos_m = math.cos(m)
sin_m = math.sin(m)
threshold = math.cos(math.pi - m)
# 用三角恒等式展开cos(theta + m) = cos(theta)cos(m) - sin(theta)sin(m)
sine = torch.sqrt(1.0 - torch.pow(cosine, 2))
phi = cosine * cos_m - sine * sin_m
# 限制phi的范围,防止角度接近pi时余弦值异常
phi = torch.where(cosine > threshold, phi, cosine - m * m)
one_hot = F.one_hot(labels, num_classes=cosine.size(1)).float()
# 目标类别用phi替换,非目标类别保持cosine
output = (one_hot * phi) + ((1.0 - one_hot) * cosine)
output *= s
return F.cross_entropy(output, labels)
这个设计的几何含义值得细品。假设间隔m取0.5弧度,也就是约28.6度,那么同一类别的特征必须比“刚好能分对”的位置再往中心收拢至少28.6度,模型才不会受到惩罚。这就形成了一个围绕类别中心的角度边界:边界内部是同类样本的聚集区,边界外部是决策的分界线。相比不加间隔的softmax,ArcFace在球面上画出了一条更宽的“隔离带”,特征空间的判别性因此大幅提升。
还有一个细节是缩放因子s。归一化之后所有logit都落在-1到1之间,这个区间太窄,softmax的梯度会非常小,训练几乎推不动。乘以一个较大的s(人脸识别中常用30到64)可以把logit的动态范围拉开,保证训练有足够的梯度信号。s和m是一对互相制约的超参数:m太小则类间间隔不明显,m太大则训练难以收敛,早期论文给出的经验组合是s等于64、m等于0.5,在LFW等公开数据集上表现很稳。另外代码里对theta接近pi的情况做了阈值截断,因为当角度加上间隔超过180度时,cos(theta + m)的行为会反转,不处理的话训练后期可能出现loss不降反升的怪现象。
三、与SphereFace、CosFace的对比及工程实践建议
基于角度间隔的损失函数其实是一整个家族,ArcFace之前有两个重要的前辈。SphereFace提出的是乘性角度间隔,也就是把theta乘以m变成cos(m*theta),这种方式间隔的大小会随角度变化,实现上还需要处理整数近似和退火机制,训练不太稳定。CosFace则是在余弦值上直接做减法,写成cos(theta) - m,实现简单、训练平稳,但它的间隔作用在余弦域而非角度域,在角度较大时区分能力会打折扣。ArcFace取了两家之长:间隔加在角度上,符合球面几何的直觉;加法形式又保证了数值稳定,梯度计算友好。三者可以统一写成一个表达式,用一个混合参数控制加性和乘性成分,这也是后来不少变体工作的出发点。
| 损失函数 | 目标类别logit形式 | 特点 |
|---|---|---|
| SphereFace | cos(m * theta) | 乘性间隔,需要退火技巧,训练复杂 |
| CosFace | cos(theta) - m | 加性余弦间隔,稳定但间隔在余弦域 | >
| ArcFace | cos(theta + m) | 加性角度间隔,几何清晰且训练稳定 |
在工程落地时有几点经验可以参考。第一,backbone输出的特征维度常见为512维,最后一层bn-dropout-fc-embedding的结构配合ArcFace效果较好,dropout的取舍可以实验对比。第二,训练数据足够大时m可以适当调大,比如从0.5调到0.55,类间分离会更明显,但小数据集上容易过拟合,反而要减小间隔。第三,推理阶段不需要加载ArcFace的分类层权重,只用backbone提取归一化后的embedding做余弦相似度比对即可,这也是特征归一化在部署阶段的另一个好处——比对前先做L2归一化,内积直接就是相似度,计算高效。第四,如果类别数达到百万级,全连接层的参数量和softmax的计算开销会很大,可以考虑用混合精度训练加上类别中心采样等策略来控制显存占用。
总的来说,特征归一化解决了“在哪里比较特征”的问题,把比较域固定在单位球面上;加性角度间隔解决了“怎么比较才更有判别力”的问题,在球面上强行划出类别的势力范围。两者配合起来,才成就了ArcFace在人脸识别领域的地位。理解了这套机制之后,再去看MagFace、AdaFace这些后续改进工作,本质上都是在间隔的设计上继续做文章,思路是一脉相承的。