在深度学习里跑过几轮人脸识别、图像检索、行人重识别(ReID)这类任务的老哥,应该都绕不开一个东西:度量学习,而度量学习里面最经典、被问得最多的就是Pytorch里的三元组损失(Triplet Loss)。
我第一次接触它是在一个面部识别Demo里,代码用的是Pytorch。当时最大的困惑不是怎么调用,而是“为什么这个人脸任务不用交叉熵,非要整个三张图绑在一起算损失?”后来把原理吃透、又把在线困难样本挖掘自己实现了一遍,才明白这一套东西的真正价值。这篇就完全基于我自己的实战经验和踩坑记录来写,从损失函数的核心原理到Pytorch实现细节、再到Batch Hard这种在线挖掘策略的手写代码,最后把训练中我碰到的典型问题整理成速查表。内容会偏工程向,尽量说人话,保证新手能看懂,老手也能找到点有用的细节。
1. 三元组损失背后的设计逻辑
1.1 为什么分类任务中它很少出现,度量任务中却是主力
先说清楚一个本质问题:分类任务里我们最终要的是“这个图片是谁”,最后一层接个Softmax,输出一个概率分布,拿交叉熵去优化就行。但真实世界的业务场景,尤其人脸识别、车辆检索、商品检索,遇到的情况往往是“训练集里的类别不是我的目标”。
举个例子,训练集里有100个人的照片,但上线后要识别的是完全没见过的200个人。这时候Softmax分类器就尴尬了,它只能预测“这100个人中的哪一位”,遇到新面孔只能瞎猜。你当然可以说“那我把训练类别扩到十万”,但真实场景下新用户是源源不断涌进来的,不可能每来一个就重新训练一次。
于是度量学习的思路来了:不学“分到哪个类”,而是学“怎么衡量两张图有多像”。本质上是学习一个映射函数f(x),把原始图像压缩成一个固定维度的特征向量(通常叫Embedding)。训练的目标是让同一个人的不同照片在特征空间里尽量靠近,不同人的照片尽量远离。上线之后,不再做分类,只看两张图的特征距离是否小于某个阈值就能判断是不是同一个人。
这正是三元组损失发挥作用的地方。它不像Softmax需要一个固定的类别列表,它只关心样本之间的相对关系:谁是同类的正样本,谁是异类的负样本。这种“相对”的学习方式,天生就适合类别人口不断变化的度量场景,也是ReID和人脸验证任务里它这么常见的原因。
1.2 锚点、正样本、负样本的三角关系
三元组损失的核心输入是三个样本,术语分别是Anchor(锚点)、Positive(正样本)、Negative(负样本)。这里的正负不是“标签正负”,而是“和Anchor是否属于同一个类别”。
- Anchor(a):当前选定的基准样本,比如“张三”的一张照片。
- Positive(p):和Anchor同一个人(同一类)的另一张照片,比如“张三”的另外一张照片。
- Negative(n):和Anchor不同人的照片,比如“李四”的照片。
在特征空间里,模型是个特征提取器,把每张图都映射成一个向量。我们希望经过训练之后,Anchor和Positive的距离尽量小,Anchor和Negative的距离尽量大。如果有多个正样本和多个负样本,最理想的情况其实也很好想象:同类特征扎堆聚成一团,不同类的团与团之间拉得远远的。
光有“拉开正负样本距离”这个目标还不够,直接优化绝对距离会有一个问题:模型只要把所有的特征都推到无穷远的地方,那负样本距离自然就被拉大了,但正样本也跟着飘远了,这明显不是我们想要的。所以三元组损失引入了“相对距离”的概念。
它的数学表达式如下:
[ \mathcal{L} = \max(d(a, p) - d(a, n) + \text{margin}, 0) ]
其中d代表两个特征向量之间的距离函数(通常是欧氏距离或余弦距离),margin是一个可调的超参数。
这个公式表达的逻辑很直白:我们不要求正样本距离绝对值有多大,我们只要求“正样本距离”比“负样本距离”近至少margin那么远就行。只要 (d(a, n) - d(a, p) \ge \text{margin}),也就是负样本已经比正样本远margin以上了,损失就是0,不再继续优化。如果负样本还不够远,差的那部分就计入损失,让梯度去调整网络。
1.3 margin到底是个什么东西,定多少合适
margin这个参数直接控制“类间应该比类内远多少”。很多人把它看成是一个简单阈值,但实际使用中它对训练稳定性影响极大。
- 如果margin设得太小,比如0.1,模型很容易就满足了,损失很快降到0,特征学得不够充分,同类和异类在特征空间里还是纠缠不清。
- 如果margin设得太大,比如2.0,那么损失函数会一直施加很大的梯度压力,模型长期处于难以收敛的状态,甚至在训练初期直接梯度爆炸。
经验上,如果使用欧氏距离且Embedding做了L2归一化,那么特征向量的模长为1,两点距离的最大值是2,margin设置在0.2到1.0之间是比较安全的起步区间。如果使用欧氏距离的平方,距离范围变成0到4,那么margin普遍要设小一些,大概0.1到0.5。如果使用余弦相似度,范围是-1到1,不太适合直接用余弦值,通常会用“1 - 余弦相似度”转成距离,再配一个0.1到0.5的margin。
我自己最常用的组合是:欧氏距离 + 不归一化Embedding + margin=1.0,然后根据实际验证集的召回率再调。这个组合在多数ReID和人脸验证任务里都非常稳。
注意:是否对Embedding做L2归一化,会直接影响距离的数值范围。如果不归一化,距离上限是多少完全取决于Embedding的模长,那margin就只能靠玄学去猜。所以新手建议先做L2归一化,再设置margin,这样可解释性强很多。
2. 从Pytorch内置函数到手写实现
2.1 TripletMarginLoss究竟做了什么
Pytorch官方封装了三元组损失:torch.nn.TripletMarginLoss。它的函数签名如下:
torch.nn.TripletMarginLoss( margin=1.0, p=2.0, eps=1e-06, swap=False, size_average=None, reduce=None, reduction='mean' )在训练循环里,你需要自行组织好一个批次的三元组,然后传给损失函数:
from torch.nn import TripletMarginLoss criterion = TripletMarginLoss(margin=1.0, p=2.0, reduction='mean') # 三个Tensor,形状都是 [B, EmbeddingDim] # anchor[i] 和 positive[i] 是同一类,negative[i] 是不同类 loss = criterion(anchor, positive, negative)它的计算流程是先分别算出d(a, p)和d(a, n),这里的p=2表示使用L2范数也就是欧氏距离,然后套入上面提到的公式,最后默认对整个批次求平均。
核心参数有几个值得展开讲:
p:距离范数类型。p=2是欧氏距离,p=1是曼哈顿距离。大部分任务欧氏距离就够了,曼哈顿距离在特征维度较高时反而容易出现梯度消失,不推荐。swap:这个参数很有用,但也最容易被忽略。它表示在计算中是否考虑“用Anchor的类别中另一个负样本替换Negative”,用于缓解随机采到的负样本太简单的问题。开启后相当于对三元组做一个额外的修正,在很多开源项目里都建议设为True。reduction:mean是默认的求均值,sum是求和,none是返回每个样本的损失。训练时用mean最稳,梯度尺度比较统一。
2.2 内置函数的三个大坑
内置函数方便归方便,但实际用起来我踩过几个很深的坑,这里专门拉出来说。
坑一:它不帮你挖三元组。TripletMarginLoss只负责计算损失,不负责帮你选三元组。如果你在训练时只是随机地把数据分成“同一类的两张图+一个不同类的图”作为一个三元组,那结果很可能非常糟糕。因为随机采到的三元组大部分都是“简单样本”,也就是负样本和Anchor本身距离已经足够远了,损失函数输出0,梯度为零,网络得不到有效训练。这就是很多人说“三元组损失训练不起来”的真正原因。其实Pytorch这句话容易让人误会——它要求你先把anchor、positive、negative三份张量组织好再喂给它。真正的挖矿逻辑要自己写。
坑二:输入顺序必须严格匹配。TripletMarginLoss(anchor, positive, negative)这三个参数是位置对应的,不能乱传。如果代码写成了TripletMarginLoss(anchor, negative, positive),语法不会报错,损失也能算出来,但优化方向直接反了,模型会越学越差。这种Bug很隐蔽,因为Loss数值是在正常范围内波动的,很难察觉。
坑三:没有做张量形状的保护。TripletMarginLoss内部要求anchor、positive、negative形状完全一致,并且p值距离是在最后一个维度上计算的。如果你做类似ReID的任务,特征提取器输出是[B, 2048]形状,没问题;但如果你不小心把三份数据拼成一个[B, 3, 2048]的张量传进去,它会在最后一个维度上计算距离,逻辑就全错了。我建议写代码时先打印三者的shape确认一遍再往后走。
2.3 为什么我建议你自己手写一个
内置函数在很多场合够用,但在“掌握每个样本的难度”这个维度上,它是明显不足的。实际训练中我们需要知道整个Batch里有多少个正样本对的损失是0、有多少个负样本的难度特别大,这时候就需要自己手写一个带信息输出的TripletLoss。
手写实现不仅没有被封装的那些坑,还能在Loss基础上顺手输出每个样本的困难程度信心值、正负样本距离对比等信息,对调试训练过程非常有帮助。
一个简洁的公式化实现可以是:
import torch import torch.nn.functional as F def pairwise_distance(embeddings): """计算批次内所有样本两两之间的欧氏距离矩阵""" dot_product = torch.matmul(embeddings, embeddings.t()) square_norm = torch.diag(dot_product) distances = square_norm.unsqueeze(0) - 2.0 * dot_product + square_norm.unsqueeze(1) distances = F.relu(distances) return distances def batch_hard_triplet_loss(embeddings, labels, margin=1.0): """ 最简单的Batch Hard实现: 对每个Anchor,选择其同类中最远的一个样本作为Positive, 选择异类中最近的一个样本作为Negative。 """ batch_size = embeddings.size(0) dist = pairwise_distance(embeddings) # 先构造一个掩码,标记同一个类别的样本对 label_equal = labels.unsqueeze(0) == labels.unsqueeze(1) # 排除自身 label_equal = label_equal ^ torch.eye(batch_size, dtype=torch.bool) # 对每个样本,找出同类中距离最大的正样本(Hard Positive) # 将异类位置置为-1,在同类距离内取max mask_pos = label_equal.float() hard_positive_dist = torch.max(dist * mask_pos, dim=1)[0] # 对每个样本,找出异类中距离最小的负样本(Hard Negative) mask_neg = (~label_equal).float() max_neg_dist = torch.max(dist) hard_negative_dist = torch.min(dist * mask_neg + (1 - mask_neg) * max_neg_dist, dim=1)[0] triplet_loss = F.relu(hard_positive_dist - hard_negative_dist + margin) return triplet_loss.mean(), dist这段代码其实就是我训练ReID模型时最常用的一段核心逻辑,后面还会在采样策略部分详细解释为什么它比内置函数更实用。
3. 三元组构建与困难样本挖掘
3.1 离线构建和在线挖掘的区别
三元组的选择方式直接决定了训练效果。业界常用方式分为两类:离线构建和在线挖掘。
离线构建,通俗点说就是每训练几个epoch,先在当前模型下把全部训练数据的Embedding计算一遍,筛出比较困难的三元组,然后在下一个epoch用这些三元组训练。它的好处是逻辑直观,坏处是每几轮要重新计算一次全量特征,代价非常大,尤其训练集是百万级人脸数据时,GPU都扛不住这个开销。
在线挖掘则完全在内存中进行:每次前向传播已经拿到当前Batch所有样本的Embedding了,直接在Batch内互相配对、计算距离、挑出困难样本、构造损失。这就是非常主流的Batch Hard和Batch All策略。在线挖掘不需要额外计算全量特征,完全基于当前Batch内部的数据分布来构建难度,计算量小得多,我实际项目中99%的情况都用在线挖掘。
3.2 Batch Hard和Batch All实现思路
Batch Hard策略的核心思路是:在一个Batch内,对每个Anchor,挑出它同类中最远的那个Positive作为正样本(最难的正样本),再挑出异类中最近的那个Negative作为负样本(最难的负样本),然后计算损失。
这样的好处是损失永远不为0,因为“最远的正样本”通常比“最近的负样本”要难满足,模型被迫去处理当前批次中最有区分度的成对关系。我的经验是这个策略训练出来的Embedding判别力非常强,收敛速度也明显优于随机采样。
Batch All策略则是在一个Batch内枚举所有有效三元组(同一个Batch里可能同时有多个正样本和多个负样本,排列组合出很多组),然后对所有三元组求平均。好处是数据利用率高,但缺点也很明显:Batch内大部分三元组是Easy样本,它们的损失是0,直接把这个比例拉的很高,相当于有效信息被稀释了。所以Batch All通常要配合一个“只保留Hard Positive和Hard Negative”的筛选逻辑才更实用。
实际操作中,我特别喜欢把两者结合一下:
- 用Batch Hard做主干损失,保证每个Anchor都有明确的梯度和优化信号。
- 再加一个“全局最困难三元组”分支,允许模型在Batch内找到最全局困难的一批三元组做补充优化。
3.3 在线挖掘中的关键代码细节
在线挖掘的核心是构造距离矩阵。我上面那节pairwise_distance函数看起来简单,但有几个细节值得展开:
第一,square_norm.unsqueeze(0) - 2.0 * dot_product + square_norm.unsqueeze(1)这个公式实际上是在用公式 ( |x-y|^2 = |x|^2 - 2x\cdot y + |y|^2 ) 批量计算两两距离。最后用F.relu把数值上因为浮点误差导致的极小负数截断,避免开根号时出现NaN。
第二,在找“最近负样本”的时候,必须先把同一个类别的样本对距离设成无穷大,否则模型会把“同类的另外一张照片”当成负样本来优化,这显然是错误的。代码里我用(1 - mask_neg) * max_neg_dist这个技巧把同类的距离覆盖成Batch内的最大值,这样min操作就不可能选中同类样本。
第三,如果Batch内每个类别的样本数量非常少(比如每个类只有两张图),那么Hard Positive就退化成唯一的一张同类图,优化信号会偏弱。这也侧面说明了为什么三元组训练对Batch Size要求高——我建议最少32张图起步,单个类别至少4张图才能发挥出Online Hard Mining的效果。
4. 一个完整的Pytorch训练示例
4.1 搭建一个简单的Embedding网络
先来搭建一个足够简单且能跑通全流程的网络,它把图片压缩成128维的Embedding向量。这个网络拿来做人脸验证这类任务足够了,图像检索任务也基本够用。
import torch import torch.nn as nn import torch.nn.functional as F class SimpleEmbeddingNet(nn.Module): def __init__(self, embedding_dim=128): super().__init__() self.conv_stack = nn.Sequential( nn.Conv2d(1, 32, kernel_size=3, stride=1, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, stride=1, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.fc = nn.Linear(64 * 7 * 7, embedding_dim) def forward(self, x): x = self.conv_stack(x) x = x.view(x.size(0), -1) x = self.fc(x) return x这个网络接受单通道28x28的灰度图(类似于手写数字),输出128维向量。
4.2 数据加载与三元组批次的组织逻辑
在线挖掘最常用的数据组织方式是:每个Batch包含P个不同类别,每个类别采样K张不同图片。这种组织方式在文献里叫“PK采样”,Batch大小就是P乘以K。
这样做有一个好处,就是在Batch内部,每个Anchor都有K-1个同类的Positive样本,还有(P-1)*K个异类的Negative样本,挖掘空间足够大,Hard Mining才有意义。
from torch.utils.data import Dataset, DataLoader class TripletDataset(Dataset): def __init__(self, images, labels, transform=None): self.images = images self.labels = labels self.transform = transform # 按类别索引组织数据,方便PK采样 self.class_to_indices = {} for idx, label in enumerate(labels): if label not in self.class_to_indices: self.class_to_indices[label] = [] self.class_to_indices[label].append(idx) self.classes = list(self.class_to_indices.keys()) def __getitem__(self, index): # 这里不按普通顺序取,而是用PK采样逻辑 pass实际实现里,最简单的处理方式是自定义一个BatchSampler,在每个迭代时随机选P个类别,再从每个类别中随机选K个样本。这块代码逻辑比较绕,但原理就是把“构造Batch”的环节独立出来。
4.3 训练循环里的关键步骤
下面是完整的训练循环骨架,包含Embedding提取、距离矩阵计算、损失计算、反向传播这几个标准步骤。关键点我都在注释里标注了。
from torch.optim import Adam from torch.utils.data import DataLoader model = SimpleEmbeddingNet(embedding_dim=128) optimizer = Adam(model.parameters(), lr=1e-3) def l2_normalize(x): return F.normalize(x, p=2, dim=1) for epoch in range(50): model.train() total_loss = 0.0 for batch_imgs, batch_labels in train_loader: # batch_imgs shape: [B, 1, 28, 28] embeddings = model(batch_imgs) # [B, 128] embeddings = l2_normalize(embeddings) # 归一化,范围[0,2] loss, distance_matrix = batch_hard_triplet_loss( embeddings, batch_labels, margin=1.0 ) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() print(f"Epoch {epoch:02d} | Loss: {total_loss / len(train_loader):.4f}")这段代码我在CPU上用Mnist模拟跑过很多次,损失曲线整体是下降趋势,同时特征空间中同类的聚类效果越来越好。
有一个非常容易踩的坑:训练时做了L2归一化,测试或推理时千万别忘了做同样的一步。有次我把模型部署到服务端时,傻傻地只做了前向传播,结果把Embedding直接去算余弦距离,数值上其实是一样的,但如果算欧氏距离就完全对不上了。所有预处理环节在训练和推理时必须保持完全一致,这一点怎么强调都不过分。
4.4 加入验证集进行模型筛选
训练过程中不能只盯着Loss曲线,因为三元组Loss降下来了,很多时候只是意味着“相对关系满足了margin”,并不代表Embedding在检索任务上真的足够好。
我建议每训练几个epoch就冻结模型,从验证集里随机挑一批同类的图片对和一批不同类的图片对,计算它们在Embedding空间里的距离分布,然后画一个简单的ROC曲线或者算一下特定阈值下的准确率。
简单实现方式:
def evaluate_validation(model, val_pairs, val_labels): """ val_pairs: [[img1_path, img2_path], ...] val_labels: [1, 0, 1, ...] 其中1代表同一人 """ model.eval() distances = [] with torch.no_grad(): for (img1, img2), label in zip(val_pairs, val_labels): emb1 = model(img1.unsqueeze(0)) emb2 = model(img2.unsqueeze(0)) emb1 = l2_normalize(emb1) emb2 = l2_normalize(emb2) dist = torch.norm(emb1 - emb2, dim=1).item() distances.append(dist) # 计算不同阈值下的准确率 best_acc = 0.0 best_threshold = 0.0 for threshold in np.arange(0.1, 2.0, 0.05): preds = (np.array(distances) < threshold).astype(int) acc = (preds == val_labels).mean() if acc > best_acc: best_acc = acc best_threshold = threshold return best_acc, best_threshold这样的验证方式比单看Loss更接近真实业务效果。我在实际项目中通常会给模型保存加一个额外的判定:只有当验证集准确率比之前所有记录都高时才覆盖保存权重,这样可以避免训练后期过拟合或损失震荡导致的最佳模型被覆盖。
5. 训练中容易踩的经典坑与排查方法
5.1 损失函数一直居高不下怎么办
损失不下降的情形我遇到过不下五次,每次原因都不同,但大部分都和三元组选择方式有关。
第一个可能原因是采样方式落后:训练时随机给三张图凑成一个三元组,根本不用挖矿策略。这样一来大多数三元组确实是简单样本,损失值在0附近波动,没法有效训练。我见过有人打印loss后发现前几百个step的loss都是0,这就说明三元组选择完全无效。
第二个可能原因是Batch Size太小。三元组损失本身对Batch Size极其敏感,Batch越小,Batch内同一个类别出现的次数可能就少,Hard Mining就容易失效。比如每个Batch只有16张图,分到4个类别,每个类别只有4张图,那Hard Positive的选择空间就非常有限。我建议起步至少32张,如果显存允许,64张或128张效果会更好。
第三个原因可能是模型容量不足。Embedding网络太浅、通道数太少,学出来的特征本身就不足以区分不同类别,距离天然就很近。这时候要适当加深模型或调整Embedding维度。
5.2 损失出现NaN的原因分析
NaN这个东西在训练三元组损失时经常遇到,但只要你认真排查,基本就是下面几个原因之一。
第一,特征向量中出现了Inf或NaN。这个通常发生在学习率设置过大时,网络权重发生爆炸。解决办法是把learning rate降低到1e-4甚至更低,同时加上梯度裁剪。
第二,距离矩阵中的数值溢出。比如pairwise_distance中如果Embedding没有归一化,特征值可能很大,平方之后直接溢出。解决方法是先做L2归一化,再算距离,既可以防止溢出,也统一了距离的数值范围。
第三,Batch中某个类别只出现了一次。那么它没有Positive样本,hard_positive_dist会被算成0或出现异常的无穷大。解决方法是增加min_k检查,在构造Batch时确保每个类别至少有2张以上的图片。
强烈建议在训练脚本中加入“Loss是否为NaN”的检查,发现NaN马上打印当前的Embedding数值、当前Batch的标签分布,快速定位问题源。我早期调试时靠这招省下了大量时间。
5.3 margin和Embedding维度怎么搭配
margin的取值和Embedding维度没有直接关系,但Embedding维度会影响特征表达的容量。
一般情况下,128维Embedding已经足够应付很多人脸和图像检索任务。如果你把维度降到64,特征表达可能会不足,训练出来的同类样本分布会变得松散。如果你把维度涨到512,训练参数变多,过拟合风险也随之增加,训练时长也会明显变长,小数据集上反而不如128维稳定。
margin主要还是跟距离度量相关。正常使用L2归一化时,距离范围是[0,2],margin从0.1到1.0之间都有人用。做个简单的实验比较一下最稳:固定其他条件,分别跑00.1、0.5、1.0、1.5,然后比较验证集准确率,哪个高就用哪个。我自己在ReID任务里的经验是0.5到1.0之间通常都能得到不错的结果,太小的margin会让同类和异类在边界处黏连,太大的margin又容易训练不稳。
5.4 在线挖掘时最容易犯的一个逻辑错误
用Batch Hard时,很多人挖负样本时忘了把“同类样本”从候选集里排除。如果某个Batch里同一个类出现了8张图,那这8张图彼此之间的欧氏距离都非常小,它们互相可能会被当成最近距离的负样本,这就会导致整个损失函数给出一个完全错误的方向。
这也是为什么我推荐用一个布尔掩码处理矩阵:
mask_neg = (~label_equal).float()再加上把同类位置覆盖成无穷大,双保险,不容易出错。
5.5 多卡训练时的额外提示
如果训练用了多张显卡(DataParallel或DistributedDataParallel),在线挖掘时每个GPU卡上独立计算Batch Hard损失,数据分布可能不一致,虽然整体能训练,但效果可能略差于单卡大Batch。多卡场景的常见做法是扩大全局Batch Size,同时保证每张卡上的Batch仍然满足“每个类别至少4张图”的条件。如果做不到,建议优先使用单卡大Batch来训练三元组模型。
6. 后续还可以怎么扩展
我实现三元组损失之后,后来又用同样的一套逻辑迁移到了多个任务上。最典型的两个方向:
一个是把TripletLoss和分类损失(Softmax)联合起来,做多任务学习。简单说就是网络同时输出Embedding和类别Logits,两个损失按一定权重相加。这样分类损失提供了强语义信号,三元组损失又保证了局部判别力,两者的组合经常能拿到比单独使用任何一个更好的结果。
另一个是把Batch Hard的思想迁移到其他度量学习损失里,比如在对齐损失、多相似度损失里也用“最困难样本挖掘”的思路。三元组损失虽然经典,但在某些任务上多相似度损失和代理损失的表现更好,而它们几乎都吸收了“困难样本很重要”这个核心思想。
我自己在训练时也养成了一个习惯,就是除了Loss值以外,每轮还会记录当前Batch里被激活的三元组比例(即loss > 0的比例)。如果这个比例特别低,说明模型已经把简单样本全部处理完了,训练难度开始上升,这时候我会适当调大margin,或者调低学习率让模型去微调。这个细节平时很少有人写,但我觉得它对掌握训练节奏非常有用。