FaceNet 的目标不是给每张人脸预测一个固定类别,而是学习一个嵌入函数,把任意人脸图像映射成紧凑的实数向量。在这个向量空间中,同一身份的两张人脸距离应当很小,不同身份的人脸距离应当足够大。三元组损失正是围绕这一目标设计的监督信号,它不需要固定数量的身份类别,也不依赖全连接分类层,因此更适合大规模人脸验证和识别任务。

三元组损失的数学定义与作用机制
三元组损失的基本单元由三张图片组成:锚点(anchor)、正例(positive)和负例(negative)。锚点和正例来自同一个身份,负例来自另一个身份。FaceNet 希望锚点与正例之间的距离,比锚点与负例之间的距离至少小一个固定间隔 margin。假设嵌入向量已经经过 L2 归一化,距离采用欧氏距离的平方,损失函数可以写成:
loss = max(d(a, p) - d(a, n) + margin, 0)。其中 d 表示两个向量之间的欧氏距离。这个式子只惩罚那些没有满足间隔条件的样本。当 d(a, p) 已经比 d(a, n) 小超过 margin 时,损失为零,模型不会为这些简单样本更新参数。
加入 margin 的意义在于避免退化解。如果 margin 为零,模型只需要让正例距离略小于负例距离就能得到很小的损失,这样嵌入空间可能极度拥挤,不同身份之间的边界很窄。设置 margin 为 0.2 或其他正值,迫使模型在正例和负例之间留出清晰的缓冲区。这个间隔越大,模型对嵌入分布的约束越强,但过大的 margin 又会导致训练初期很难收敛,因为网络需要先学会基本的人脸特征才能拉开距离。
FaceNet 嵌入网络与 L2 归一化
FaceNet 的主干网络可以使用 Inception、ResNet 等深度卷积结构。网络接收裁剪对齐后的人脸图像,输出一个 128 维或 512 维的特征向量。最后一步对特征做 L2 归一化,使所有嵌入都落在单位超球面上。这一操作把比较相似度的问题转化为球面上的距离问题,欧氏距离和余弦相似度在这里具有等价关系。
为什么要做归一化?如果不归一化,网络可以通过简单放大特征向量的模长来增加类间距离,这种增长并不反映真实的人脸相似度,还会让损失函数变得不稳定。L2 归一化之后,损失只关注方向上的差异,嵌入空间的几何性质更加可控。很多后续人脸识别模型也沿用了这一设计。
下面是一个简化版的 FaceNet 嵌入网络实现,它用几层卷积提取特征,最终输出 128 维归一化向量:
import torch
import torch.nn as nn
import torch.nn.functional as F
class FaceNetEmbedding(nn.Module):
def __init__(self, embedding_dim=128):
super().__init__()
self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3)
self.bn1 = nn.BatchNorm2d(64)
self.pool = nn.MaxPool2d(3, stride=2, padding=1)
self.conv2 = nn.Conv2d(64, 128, kernel_size=3, padding=1)
self.bn2 = nn.BatchNorm2d(128)
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(128, embedding_dim)
def forward(self, x):
x = F.relu(self.bn1(self.conv1(x)))
x = self.pool(x)
x = F.relu(self.bn2(self.conv2(x)))
x = self.avgpool(x)
x = torch.flatten(x, 1)
x = self.fc(x)
x = F.normalize(x, p=2, dim=1)
return x
实际工程中会使用更深的预训练网络作为骨干,但上述结构足以说明嵌入层的工作方式。训练时输入并不是一张图片,而是一个批量的三元组,网络对锚点、正例和负例分别前向传播,共享同一套权重。
三元组挖掘:从随机采样到在线难例挖掘
三元组损失的效果很大程度上取决于如何选择正例和负例。如果随机从训练集中挑选负例,绝大多数负例与锚点差异明显,损失会很快降到零,网络得不到有效梯度。为了避免这种问题,FaceNet 提出在线负例挖掘策略。每个训练批次内,先计算所有样本两两之间的距离矩阵,然后为每个锚点选择最难正例和最难负例。
所谓最难正例,是指与锚点距离最大的同一身份样本;最难负例,是指与锚点距离最小的不同身份样本。用最难正例和最难负例计算损失,可以保证梯度始终来自当前网络最容易混淆的样本,训练更有效率。不过纯粹使用最难负例也可能让训练不稳定,因为早期网络还没学到可靠特征,最难负例往往噪声较大。实际中常见折中方案是半难例挖掘,即选择满足 d(a, p) 小于 d(a, n) 小于 d(a, p) + margin 的负例。
下面给出批量难例挖掘的三元组损失实现:
import torch
import torch.nn.functional as F
def batch_hard_triplet_loss(embeddings, labels, margin=0.2):
device = embeddings.device
pairwise_dist = torch.cdist(embeddings, embeddings, p=2)
n = embeddings.size(0)
labels = labels.unsqueeze(1)
mask_same = (labels == labels.t())
mask_diff = (labels != labels.t())
eye = torch.eye(n, device=device).bool()
mask_same = mask_same & ~eye
positive_dist = pairwise_dist.clone()
positive_dist[~mask_same] = 0.0
hardest_positive = positive_dist.max(dim=1, keepdim=True)[0]
negative_dist = pairwise_dist.clone()
negative_dist[~mask_diff] = float('inf')
hardest_negative = negative_dist.min(dim=1, keepdim=True)[0]
loss = F.relu(hardest_positive - hardest_negative + margin)
return loss.mean()
这段代码先计算一个批次内所有嵌入之间的欧氏距离矩阵,再根据标签生成同身份和不同身份的掩码。最难正例来自同身份掩码下的最大距离,最难负例来自不同身份掩码下的最小距离。最后按公式聚合损失。注意这里只使用最难样本,如果希望使用半难例策略,可以在负例掩码中加入距离区间限制。
训练细节与常见问题
FaceNet 原始论文使用了较大的批次来保证每个批次内有足够的身份类别和正负样本组合。批次大小通常设置得非常夸张,例如 1800 张图。对于普通开发者来说,显存有限时可以采用身份采样策略:每个批次抽取固定数量的身份,每个身份再抽取固定数量的图片。这样既控制了显存占用,又能保证每个锚点都有正例和大量负例候选。
学习率和优化器方面,通常使用 Adam 或 SGD 配合余弦退火。margin 一般设置在 0.2 左右,嵌入维度常用 128 或 512。如果训练开始阶段损失居高不下,可以适当降低 margin,或者先用 softmax 分类预训练主干网络,再切换到三元组损失微调。预训练能够为嵌入空间提供较好的初始分布,显著降低三元组训练的收敛难度。
另一种常见问题是损失塌缩。当模型把所有样本映射到相同或少数几个向量时,距离矩阵会变得非常小,损失虽然很低,但嵌入没有任何判别能力。此时需要检查 L2 归一化是否生效、批内身份是否过少、margin 是否过小。引入适量难例挖掘,或者联合使用 softmax 损失与三元组损失,是缓解塌缩的有效方法。最终评估时,可以直接使用嵌入向量之间的欧氏距离或余弦相似度做验证,不需要重新训练分类器。
通过三元组损失训练,FaceNet 将人脸识别从封闭集分类问题转变为开放集相似度检索问题。理解其距离约束、采样策略以及训练细节,能够帮助在实际项目中更稳定地完成人脸嵌入学习。