【重识别系列02】升级也需小窍门:用Tricks增加行人重识别的识别率
前言
在上一篇【重识别系列01】中,我们从零搭建了一套极其纯净的行人重识别(Person Re-ID)强基线系统。依靠标准的 ResNet50 骨干网络,结合交叉熵和三元组损失,我们在 Market1501 数据集上跑出了 Rank-1: 86.25%, mAP: 69.27% 的成绩。
但如果你翻阅过罗浩等人的经典论文《Bag of Tricks and A Strong Baseline for Deep Person Re-identification》,就会发现,想把 Rank-1 刷到 94% 以上,并不需要盲目堆砌复杂的网络分支,而是需要一些精妙的工程技巧(Tricks)。
今天这篇博客,我们就来给上一篇的代码“升升级”。我们将逐一实装修改最后步长、学习率预热、标签平滑、数据再增强和中心损失函数这几个提分利器。他们不需要增加额外的显存开销,却能让模型的识别率迎来质的飞跃。
插个题外话,上一篇中的Triplet.py中,使用了一个底层的矩阵乘加函数addmm_。在旧版 PyTorch 中,它的传参顺序是先传系数,再传矩阵。而在较新的 PyTorch 版本中,官方要求必须先传矩阵,系数必须用命名参数(beta=, alpha=)来指定。
dist.addmm_(1, -2, inputs, inputs.t())
改成
dist.addmm_(inputs, inputs.t(), beta=1, alpha=-2)
否则会有
UserWarning: This overload of addmm_ is deprecated:
addmm_(Number beta, Number alpha, Tensor mat1, Tensor mat2)
Consider using one of the following signatures instead:
addmm_(Tensor mat1, Tensor mat2, *, Number beta = 1, Number alpha = 1) (Triggered internally at C:\actions-runner\_work\pytorch\pytorch\pytorch\torch\csrc\utils\python_arg_parser.cpp:1853.)
dist.addmm_(1, -2, inputs, inputs.t())
报错风险,但是其实问题不大,编译器都能识别。另外,由于加入了中心损失函数,所以整个文件夹的结构发生了改变:
REID_TRIPLE_TRICKS/
├── data/
│ └── Market-1501-v15.09.15/
│ ├── bounding_box_train/
│ ├── bounding_box_test/
│ └── query/
├── dataset.py
├── model.py
├── sampler.py
├── train.py
├── Triplet.py
├── center_loss.py
├── val.py
└── blog.md
最后步长
什么是步长?
在卷积神经网络中,卷积核就像是一个在图像上移动的“放大镜”。而stride的作用就是控制这个放大镜每次移动的距离。当stride = 1时,放大镜每次移动1个像素。滑动结束后,输出的特征图大小基本保持不变。当stride = 2时,放大镜每次跨越2个像素。这就相当于每隔一个像素采样一次,最终输出的特征图长和宽都会缩小一半(即所谓的下采样)。
所以我们在model.py代码中做出以下修改:
def __init__(self, num_classes=751):
super(ReIDResNet50, self).__init__()
# 加载 torchvision 官方预训练 ResNet50
resnet = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1)
# Trick 1: Last Stride = 1
# 移除 layer4 的空间下采样,保留更丰富的细粒度特征
# 修改 layer4 第一个 Bottleneck 的 3x3 卷积步长
resnet.layer4[0].conv2.stride = (1, 1)
# 修改 layer4 第一个 Bottleneck 的残差连接(downsample)步长
resnet.layer4[0].downsample[0].stride = (1, 1)
# 去掉最后的池化层和分类层
self.backbone = nn.Sequential(*list(resnet.children())[:-2])
首先第一步我们要修改主路。在 torchvision 实现的 ResNet50 中,网络被分为了 4 个阶段(layer1 到 layer4)。layer4 是最后一个阶段,它包含 3 个“瓶颈块(Bottleneck)”。layer4[0] 就是指这 3 个瓶颈块中的第一个。每个 Bottleneck 内部有三个卷积层:1x1、3x3、1x1。其中,中间的那个 3x3 卷积层被称为 conv2。
在标准的 ResNet50 设计中,每个阶段的第一个瓶颈块都要承担“缩小特征图”的任务。所以原版代码里,layer4[0].conv2 的默认步长是 (2, 2)。我们将它强行改成 (1, 1),卷积核滑动时就不再跳步了,特征图的空间尺寸(比如 16x8)就被完整保留了下来。
然后我们修改旁路。ResNet 的精髓在于“残差连接”(Shortcut)。输入张量 xxx 兵分两路:一路去经历上面说的 conv1 -> conv2 -> conv3(主路得到 F(x)F(x)F(x)),另一路什么都不做直接绕过去(旁路保留 xxx),最后两者相加得出结果:F(x)+xF(x) + xF(x)+x。
要做加法,主路和旁路输出的张量形状必须完全一样。在原版设计中,因为主路的 conv2 步长是 2,导致主路输出的图像变小了一半;同时,通道数也从 1024 变成了 2048。为了能做加法,原版 ResNet 在旁路上专门装了一个叫 downsample 的小模块(里面包含一个 1x1 卷积 downsample[0] 和一个 BN 层),用来把旁路的图像也强制缩小一半,并把通道数对齐到 2048。
由于我们在第一步里,已经阻止了主路图像变小,所以主路输出的是 16x8 的大图。如果我们不修改旁路,旁路的 downsample[0] 依然会按照步长为 2 把图像变成 8x4。到时候 16x8 加 8x4,张量尺寸不匹配,PyTorch 就会报错。因此,我们必须把旁路这个 1x1 卷积的步长也改成(1, 1),让两条路的图像大小保持一致,顺利完成相加。
学习率预热
为什么要预热?
在我们的模型中,ResNet50 骨干网络是基于 ImageNet 预训练好的模型。它就好比一台拥有精密的内部齿轮,并且磨合得非常完美的跑车引擎。然而,我们在网络末端新加的 BNNeck层和 Classifier层却是随机初始化的,就像是给这台跑车临时安了一个并不适配的方向盘。
如果在训练一开始,我们就直接把油门踩穿,使用较大的初始学习率,这些随机初始化的层在反向传播时会产生巨大的、极其混乱的梯度。这些巨大的梯度会像洪流一样,直接“冲垮”并破坏掉骨干网络中那些原本已经预训练好的精密权重。
为了解决这个问题,学术界常用的手段是“学习率预热”。也就是在训练的最初几个 Epoch(比如前 10 轮),让学习率从一个极小的值慢慢线性增长到我们的基础学习率。等到新加的层被“调教”得差不多了,整个网络再一起以正常的速度学习。随后,再在特定的轮数(如第 40 轮和第 70 轮)按比例衰减学习率,让模型在后期进行精细收敛。
在 train.py 中,我们可以通过自定义一个学习率调整函数来实现:
# Trick 2: 学习率调整函数
def adjust_learning_rate(optimizer, epoch):
"""
按照论文公式:
0-10 epoch: 从 3.5e-5 线性预热增加到 3.5e-4
10-40 epoch: 保持基础学习率 3.5e-4
40-70 epoch: 学习率衰减为 3.5e-5
70-120 epoch: 学习率再衰减为 3.5e-6
"""
if epoch < 10:
# 线性预热公式
lr = 3.5e-5 + (3.5e-4 - 3.5e-5) * (epoch + 1) / 10
elif epoch < 40:
lr = 3.5e-4
elif epoch < 70:
lr = 3.5e-5
else:
lr = 3.5e-6
for param_group in optimizer.param_groups:
param_group['lr'] = lr
return lr
然后在训练循环中,每个 Epoch 开始前调用它即可动态调整当前的学习率:current_lr = adjust_learning_rate(optimizer, epoch)。另外,为了更大化地还原原本论文中的数据,我将训练的总 Epoch 也从原本的60改到了120。
标签平滑
在标准的交叉熵损失计算中,我们给模型的真实标签是绝对的独热编码(One-Hot)。比如,如果这张图片属于第 3 个人,标签就是 [0, 0, 1, 0, ...]。
这种绝对的 1 和 0 会逼迫模型产生一种“盲目自信”:它会拼命拉大正确类别与错误类别的得分差距,试图把预测概率无限推向 100%。在行人重识别这种类别多,但每类样本少的任务中,这种追求绝对完美的倾向极易导致过拟合。模型会死记硬背训练集里的特定衣服褶皱或背景,一旦到了测试集遇到没见过的人,就没办法了。
标签平滑(Label Smoothing)就是来打破这种绝对自信的。它通过引入一个极小的常数 ϵ\epsilonϵ(通常取 0.1),硬生生地把真实标签里的 1 削弱成 0.9,然后把剩下的 0.1 均匀地分给其他所有错误的类别。这就相当于在告诉模型:“你大概率是正确的,但我们讲凡事留一线,不要太绝对。”这种轻微的惩罚机制,能让模型在特征空间中聚类得更加紧凑,显著提升泛化能力。在最新的 PyTorch 版本中,实现标签平滑甚至都不需要我们手动写复杂的数学公式,官方直接在交叉熵损失函数中内置了这个参数,只需要修改 train.py中的一行代码即可:
# Trick 3: Label Smoothing (标签平滑)
id_criterion = nn.CrossEntropyLoss(label_smoothing=0.1)
数据再增强
在理想的实验环境里,摄像头拍到的人都是全身完整的。但在真实的街头监控中,行人往往会被各种物体遮挡:比如下半身被绿化带挡住,或者手里提着个大塑料袋挡住了衣服的图案。如果我们的模型在训练时只看过“完美”的图片,它很容易对某些局部特征产生依赖,比如只靠认衣服上的图案来识别人。一旦这个图案被遮住,模型就只能瞎猜了。
随机遮挡的逻辑极其粗暴但有效:在模型训练读取图片时,我们以一定的概率(通常是 50%),在图片上随机框出一个矩形区域,并用随机的彩色像素把它死死盖住。这就相当于强迫模型跳出舒适区,既然最明显的特征可能随时被挡住,模型就必须去学习全局的、更深层次的特征(比如整体的身材比例、没有被挡住的边缘细节)。
同样,街头摄像头抓拍到的行人不可能永远端端正正地站在画面最正中央。所以也不能让模型死记硬背“头在画面顶部、脚在底部”的绝对位置。
为了让模型具备“平移不变性”,我们在数据增强管道中引入了 Pad 和 RandomCrop。具体操作是:先在 256x128 的图像外围填充一圈 10 个像素的黑边,然后再在这个变大的图像里随机裁出一个 256x128 的矩形。这样一来,同一张图片每次喂给模型时,行人在画面中的位置都会有轻微的像素级偏移,配合前文提到的随机遮挡,能极大提升模型的鲁棒性。
在较新的 PyTorch 版本中,官方已经把这些工具内置在了 torchvision.transforms 里,我们只需要在 train.py 的数据增强部分里加即可:
# Trick 4: 随机遮挡 (Random Erasing)
transform_train = transforms.Compose([
transforms.Resize((256, 128)),
transforms.RandomHorizontalFlip(),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
# p=0.5: 50%概率触发擦除,value='random'代表用随机像素填充
transforms.RandomErasing(p=0.5, scale=(0.02, 0.4), ratio=(0.3, 3.33), value='random')
])
需要特别注意的是,transforms.RandomErasing 必须放在 transforms.ToTensor() 之后。因为它只能对PyTorch的Tensor格式数据进行操作,顺序写错会直接报错。
中心损失函数: center_loss.py
编写中心损失函数
在上一篇中,我们引入了三元组损失。它的核心逻辑是比对“相对距离”:只要保证“同一个人的两张照片”之间的距离,比“两个不同人的照片”之间的距离更近就可以了。这就像是一个只管排名的考试,只要你比别人分数高就行,不管你到底考了多少分。
但这会带来一个隐患:它只管相对距离,不管绝对位置。导致的结果是,属于同一个人的照片特征,在高维的特征空间里,可能散落得非常开。这种“类内不紧凑”的特征,在遇到极其相似的人时,很容易发生误判。
中心损失就是为了解决这个问题而诞生的。它的思路是:在高维空间里,为每一个类别(比如Market1501的751个人)强行设立一个“中心坐标点”。在训练的过程中,它会把属于这个类别的所有特征,死死地往这个中心点上拉扯。有了中心损失的加持,同一个人的特征会紧紧抱团,极大地增加了类内的紧凑度。
由于 PyTorch 原生没有提供中心损失函数,我们需要新建一个 center_loss.py 文件,自己实现这个逻辑:
import torch
import torch.nn as nn
class CenterLoss(nn.Module):
def __init__(self, num_classes=751, feat_dim=2048):
super(CenterLoss, self).__init__()
self.num_classes = num_classes
self.feat_dim = feat_dim
# 核心:创建一个可以被优化的参数矩阵,这就是 751 个人在 2048 维空间里的"中心点"
self.centers = nn.Parameter(torch.randn(self.num_classes, self.feat_dim))
def forward(self, x, labels):
batch_size = x.size(0)
# 找出当前 batch 里,每个样本对应的那个"中心点"坐标
centers_batch = self.centers.index_select(0, labels)
# 计算样本特征 x 和它对应的中心点 centers_batch 之间的 L2 距离的平方
diff = x - centers_batch
loss = (diff.pow(2).sum() / 2.0) / batch_size
return loss
这段代码有两处需要解释说明一下:
首先是初始化函数__init__中的:self.centers = nn.Parameter(torch.randn(self.num_classes, self.feat_dim))
函数内层的torch.randn会生成一个751 行、2048 列的随机矩阵(以 Market1501 数据集为例)。它的意义在于:在 2048 维的浩瀚空间里,给这 751 个人随机在大地图上插了 751 面小旗子,作为他们初始的“集合点”。而外层加了nn.Parameter包装。如果不加这个包装,普通的张量只是一个静态数字变量,无法参与反向传播。用 nn.Parameter() 包裹后,PyTorch 会自动把它识别为神经网络自身拥有的可训练权重(就像卷积核一样)。这意味着,这些中心点坐标不是死死不动的,它们会跟着反向传播不断移动。
换句话说,如果不加 nn.Parameter,751根小旗子就是751根死柱子。训练时,虽然模型在努力把同一个人的照片往柱子方向拉,但因为柱子无法移动,如果初始位置钉得不好,大家可能怎么拉都聚不拢。
而加上了 nn.Parameter,就相当于把这些柱子装上了轮子。当某个人的照片在拼命往柱子方向靠拢时,柱子也会根据大家的分布,自动调整自己的位置,往照片最密集的地方开,实现“双向奔赴”。
另一处在 forward 函数中,最核心的是以下三行矩阵计算:
# 1. 查表操作:找出当前 batch 里,每个样本对应的那个"中心点"坐标
centers_batch = self.centers.index_select(0, labels)
# 2. 计算样本特征 x 和它对应的中心点之间的差值
diff = x - centers_batch
# 3. 计算 L2 距离的平方,并对整个 batch 取平均
loss = (diff.pow(2).sum() / 2.0) / batch_size
index_select(0, labels)是在干什么?
参考我们之前在三元组采样器里设置的 batch_size = 64,是由 16 个人的 4 张图组成的一个batch。这 64 张图片的真实标签(labels)分别代表着某 16 个人。
index_select(0, labels) 的作用就像是一个高效的“查表员”。它会去我们刚刚定义的 751 行大矩阵里,把这 16 个人的中心点坐标精准地抽出来.但注意:因为每个人在这个 batch 里出现了 4 次,所以同一个中心点会被重复抽取 4 次。于是最终拼成一个形状为 (64, 2048) 的新矩阵 centers_batch。这样,当前batch里的 64 个样本特征 x,就能和它们各自对应的“集合点”坐标一一对齐。labels前面的 0 代表提取的是每个labels所代表的那一行特征向量中心点坐标。因此,centers_batch[i] 就是第 i 个样本所属类别的中心点坐标,与样本特征 x[i] 一一对应,方便后续计算差值x - centers_batch。
对齐之后,diff = x - centers_batch让每个样本的特征去减去它对应的中心点坐标,得到了一个距离差值向量。
接下来的 diff.pow(2).sum() / 2.0 就是用来计算欧氏距离。最后除以 batch_size 转化为平均损失,拉动整个网络进行反向传播。
值得一提的是,这里的除以2.0是一个在机器学习损失函数里极其常见的“数学洁癖”。因为特征相减算的是平方(pow(2)),在反向传播求导数时,平方项会产生一个系数2。公式里提前除以 2.0,就是为了在求导时把这个2给消掉,让最终算出来的梯度变得干净利落,没有多余的常数。
当然,从实际操作的角度来说,就算不除以这个 2.0 也没任何关系,因为整个 Center Loss 外面反正还要乘以一个权重系数(比如 0.0005),不除以2顶多也就是让梯度的绝对值翻倍了,可以通过微调外面的权重系数或者学习率来抵消。但遵循原论文的数学规范除以2,能让整个系统的梯度流在数学推导上显得更加优雅。
训练时加入中心损失
回到我们的train.py,要把中心损失加进去,我们需要对优化器和总损失计算进行修改。
首先实例化这个损失函数,对于中心损失和三元组损失,我们需要分离优化器,并手动放大梯度。因为 Adam 带有动量,且学习率极小。我们在高维空间里随机扔下的 751 个中心点坐标,在 Adam 的拖累下几乎移动得像蜗牛一样慢。这就导致骨干网络为了强行凑上这些错误的“死坐标”,拼命扭曲自己的特征提取逻辑,最终彻底丧失泛化能力。我们需要让骨干网络用 Adam 慢跑,而给中心点单独配置 SGD 优化器,并赋予 0.5 的极大学习率让它狂奔。
# Trick 5: 实例化 Center Loss
center_criterion = CenterLoss(num_classes=751, feat_dim=2048).to(device)
# 1. 主模型优化器(只管骨干网络)
optimizer = optim.Adam(model.parameters(), lr=0.00035, weight_decay=5e-4)
# 2. Center Loss 专属优化器(官方源码标准配置:SGD, lr=0.5)
optimizer_center = optim.SGD(center_criterion.parameters(), lr=0.5)
然后,在训练的循环内部,我们需要将三个 Loss 加起来。根据论文实验,给中心损失乘上一个0.0005的权重系数是最完美的平衡点。
但这又引出了第二个问题:传导给中心点坐标的梯度也被这 0.0005 缩小了 2000 倍!为了让中心点保持高速更新,我们必须在反向传播后,强行把中心点的梯度给乘回来:
# 计算三个 Loss
loss_id = id_criterion(cls_score, labels)
loss_tri = tri_criterion(global_feat, labels)
loss_center = center_criterion(global_feat, labels)
# 按照论文配置的总损失公式:beta = 0.0005
loss = loss_id + loss_tri + 0.0005 * loss_center
# --- 双优化器独立更新流程 ---
optimizer.zero_grad()
optimizer_center.zero_grad()
loss.backward()
# 1. 先更新主模型
optimizer.step()
# 2. 官方核心 Trick:手动抵消 0.0005 的权重缩放,保证中心点高速移动
for param in center_criterion.parameters():
param.grad.data *= (1. / 0.0005)
# 3. 更新中心点坐标
optimizer_center.step()

模型测试与评估
测试脚本的内容相比较上一篇来说没有任何区别,最终我们输出的结果是:
Results: Rank-1: 90.74%, mAP: 76.55%
可以看出比上一次明显高出不少,但是距离论文中的还是有一些差距。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)