拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Fast R-CNN深度解析:核心原理、端到端训练与完整复现指南

Fast R-CNN深度解析:核心原理、端到端训练与完整复现指南

先说结论:如果你的研究方向是目标检测,或者正准备入门检测算法的演进史,那Fast R-CNN是你绕不开的一个模型。它发表于ICCV 2015,作者是Ross Girshick,也就是R-CNN的原班人马。它的出现直接把R-CNN的训练速度提升了约9倍、测试速度提升了约200倍,而且在PASCAL VOC 2007上mAP从R-CNN的66%左右直接拉到70%。更关键的是,它第一次把目标检测的训练流程真正变成一个端到端可微分的整体,这件事对后续Faster R-CNN、Mask R-CNN、Cascade R-CNN等一堆模型产生了直接影响。

这篇文章我会从设计思路、核心原理、训练复现、踩坑实录四个角度把它讲透。不求你把每一行代码背下来,但希望你看完之后能明白:RoI Pooling到底在干嘛?多任务损失为什么能work?复现的时候哪些参数不能乱改?这些才是真正值钱的东西。

1. 项目概述:Fast R-CNN在目标检测演进中的位置

1.1 从R-CNN到Fast R-CNN:解决的核心痛点

目标检测这个任务,简单说就是两件事:找到目标在哪里(定位),判断目标是什么(分类)。在R-CNN之前,主流做法还停留在滑动窗口加手工特征的老路上,计算量大、精度低。R-CNN算是第一个把深度学习大规模引进目标检测的里程碑,但它有一个致命问题:速度太慢。原因很直接,R-CNN对每张测试图片先生成约2000个候选区域,然后把这2000个候选区域逐个缩放到固定尺寸,再分别送入卷积神经网络提取特征、做分类和回归。也就是说,一张图过一遍网络,实际上要跑2000次前向传播。我当时第一次跑R-CNN的时候,测一张图等了好几秒,当时就意识到这玩意儿在生产环境根本没法用。

Fast R-CNN的思路很聪明:既然2000个候选区域来自同一张图,那何不先把整张图只过一遍卷积网络,得到完整的特征图,然后在这张特征图上根据候选区域的位置信息去“抠”出对应的特征块,再做分类和回归?这样一来,一次前向传播就把所有候选区域的特征提取都解决了。这个看似简单的思路,实际上把检测速度从“候选区域数量×单次前向耗时”压缩到“1次前向+2000次轻量级RoI处理”,量级差异直接决定了它能不能落地。

1.2 核心贡献与关键指标一览

Fast R-CNN有三个核心贡献,我用三句话概括:

  • 多任务损失:把分类损失和边框回归损失放在同一个网络里联合优化,训练不再分阶段,梯度可以同时回传到共享的卷积层。
  • RoI Pooling层:把不同尺寸的候选区域特征统一池化成固定尺寸,让网络可以接受任意尺寸输入并输出定长特征。
  • SVD分解加速全连接层:用两个小子矩阵替代大的权重矩阵,把全连接层的计算量降一个数量级。

在PASCAL VOC 2007测试集上,Fast R-CNN的mAP达到70.0%,比R-CNN的66.0%高了4个点,同时单张图像测试时间(不含候选区域生成)大约0.2秒左右,相比R-CNN的十几秒是质的飞跃。这里提一句,VOC 2007的mAP评估方式是把20个类别的AP(Average Precision)做平均,理解这个指标的底层逻辑对后续调参很有帮助,后面我会专门说。

1.3 适合谁读这篇博文

这篇文章适合三类人。第一类是刚接触目标检测、想理清算法演进脉络的学生或转行工程师,你可以把Fast R-CNN当一个“解剖样本”,理解它就能更顺滑地理解后续所有两阶段检测器。第二类是要在科研或工作中复现Fast R-CNN的开发者,我会给出环境搭建、数据准备、训练评估的完整流程和参数细节。第三类是正在做算法选型的人,你需要知道Fast R-CNN的优势和瓶颈在哪里,以及什么时候该用它、什么时候应该直接上Faster R-CNN。

2. 整体设计与思路拆解

2.1 R-CNN三阶段流程的尴尬之处

要理解Fast R-CNN的设计,必须先把R-CNN的痛点解剖清楚。R-CNN的完整流程是:先用Selective Search生成约2000个候选框,然后把每个候选框裁剪出来并缩放到227×227,分别送入AlexNet或VGG16提取特征,最后用SVM做分类、用线性回归做边框修正。

这个流程有三个明显问题。第一,重复计算严重,2000个候选框之间存在大量重叠区域,这些区域的特征被反复计算,效率极低。第二,训练复杂度高,CNN特征提取、SVM分类器、边框回归器三部分是分开训练的,每个阶段都需要单独调参和保存中间结果,非常繁琐。第三,CNN的输入尺寸固定,因为全连接层要求输入特征维度固定,所以所有候选框都必须缩放到同一尺寸,这会造成物体形变和信息丢失。

Fast R-CNN把这些痛点逐个击破。它把特征提取统一到整图一次前向计算中,用RoI Pooling解决尺寸固定问题,用softmax分类器取代SVM,把分类和回归放进同一个损失函数中联合训练。这一步设计上的“合并同类项”,在逻辑上是非常漂亮的。从工程角度看,它少了很多中间文件管理,从算法角度看,共享卷积特征让梯度能同时更新底层特征提取器,特征本身也变得更有判别力。

2.2 端到端训练思路的成立条件

所谓端到端,就是从原始图像输入到最终分类和回归输出,中间所有模块都在同一个优化目标下联动更新。但这里有个隐含前提:候选区域的生成必须是独立的。Fast R-CNN并没有把候选区域生成纳入网络内部,它仍然依赖Selective Search这类外部算法。所以严格来说,Fast R-CNN算是“半端到端”。注意这个区别很重要,Faster R-CNN后面做的就是把这个外部模块也塞进网络里,才真正实现了完全端到端。

端到端训练为什么效果好而且训练快?因为梯度可以一路回传到共享的卷积层,让特征提取器直接为分类和回归两个任务服务。R-CNN阶段,特征提取网络的训练目标只是图像分类(ImageNet预训练),它并不知道下游要做检测框回归,所以提取出来的特征未必是检测任务最优的。Fast R-CNN用多任务损失联合优化后,VGG16的卷积层能同时感知分类和定位需求,相当于让“眼睛”为“双手”服务,任务对齐度更高了。

2.3 为什么选择RoI Pooling而不是直接全局池化

当时很多人在处理不定长特征时,会直接用全局平均池化或者把候选框直接缩放到固定patch。Fast R-CNN选择RoI Pooling的原因有两个。一是保持空间对应关系。直接缩放整图会破坏物体比例,全局平均池化则丢失了空间位置信息,而RoI Pooling是在候选区域对应的小块特征图内部做网格化池化,既保留了相对空间布局,又能输出固定长度。二是让反向传播可以精确地回到RoI对应的特征图区域,这一点对端到端训练至关重要。

我用一个生活化类比解释RoI Pooling:想象你有一堆大小不一的照片,想统一放进同样大小的相册格子。RoI Pooling不是把整张照片拉伸变形,而是先把照片按目标位置裁出来,再在每个格子里取一个“最有代表性的像素块”,然后用这些块拼成统一尺寸。这样既保住了目标的位置结构,又不破坏比例。

3. 核心细节解析与实操要点

3.1 RoI Pooling的数学原理与实现细节

RoI Pooling层的输入有两部分:一是整图通过卷积网络得到的特征图,其尺寸为C×H×W,二是N个候选区域在原始图像上的坐标,每个候选区域用五元组表示(batch_index, x1, y1, x2, y2)。操作分三步。

第一步,把候选区域坐标从原始图像尺度映射到特征图尺度。假设原始图像尺寸为img_w×img_h,特征图尺寸为feat_w×feat_h,网络总步长为stride,那么映射关系是x1_feat = x1 / stride。VGG16在普通输入尺寸下总步长为16,所以一个原图上160像素的坐标,映射到特征图上就是10个像素。

第二步,把映射后的矩形区域划分成H×W的网格。比如输出要7×7的固定尺寸,那就把这个RoI区域均匀切成7行7列共49个格子,每个格子的尺寸约为(h_roi/H) × (w_roi/W)。

第三步,对每个格子内的特征值做最大池化,得到单一个值。49个格子对应49个值,按顺序排列就得到了C×7×7的输出特征。这里的通道数C保持不变,VGG16最后卷积层输出512个通道,所以RoI Pooling输出就是512×7×7。

实现上有两个容易踩坑的地方。一是坐标取整。如果RoI边界落在像素之间,通常用最近邻取整,这会造成轻微的位置偏移,但实践中影响不大。二是量化误差。因为池化网格边界也需要对整数像素位置取整,会丢失部分空间精度。后来Mask R-CNN提出的RoIAlign就是专门解决这个量化问题的,你可以理解为它用了双线性插值取代取整,精度更高。复现Fast R-CNN时,你直接用取整版本没有问题,但心里要清楚误差来源在哪里。

3.2 多任务损失函数的设计逻辑

Fast R-CNN的损失函数由两部分组成:分类损失加边框回归损失,写成表达式就是:

L = L_cls + λ * L_reg

分类损失用的是softmax交叉熵。注意这里Fast R-CNN并没有保留R-CNN的SVM分类器,而是直接用全连接层加softmax输出21个类别的概率(以VOC为例,20个物体类加1个背景类)。为什么换成softmax?因为这样分类和特征提取可以在同一个反向传播流程中优化,SVM是外部模型,梯度根本回不到网络里。

回归损失使用的是smooth L1损失。它的定义是:

  • 当|x| < 1时,0.5 * x^2
  • 否则|x| - 0.5

smooth L1的好处是对离群点不敏感。如果直接用L2损失,一个标注框轻微标注偏差就可能导致梯度爆炸,训练非常不稳定。而smooth L1在误差较大时梯度绝对值恒定为1,可以避免震荡。在我实际训练中,这个设计确实让训练稳定很多,尤其是在数据标注质量不高的时候,smooth L1明显比L2更能扛。

边框回归的目标不是直接预测坐标值,而是预测相对偏移量。具体来说,候选框坐标为(x, y, w, h),真实标注框为(x*, y*, w*, h*),回归目标定义为:

t_x = (x* - x) / wt_y = (y* - y) / ht_w = log(w* / w)t_h = log(h* / h)

也就是说,模型学习的是候选框到真实框的归一化平移量和缩放量。为什么要归一化?因为不同尺寸的候选框,其像素偏移的绝对值差异巨大,直接预测绝对像素值会让大框和小框的回归难度不一致。归一化之后,模型只需要学一个相对位移,尺度不变性大大增强。

3.3 SVD分解如何给全连接层提速

Fast R-CNN里还有一个不太起眼但很实用的工程细节:用SVD分解加速全连接层。VGG16全连接层权重矩阵非常大,fc6是4096×25088,fc7是4096×4096。推理时,2000个RoI都要过这两个全连接层,计算量可想而知。

SVD的思想是:把一个大权重矩阵W近似分解成三个小矩阵乘积W ≈ U·S·V^T,然后只保留前k个奇异值。这样原来的一次大矩阵乘法W·x就变成了两次小矩阵乘法U·(S·(V^T·x)),计算量从m×n降为k×(m+n)。在实验中,当k取128左右时,全连接层计算量大约降低一个数量级,而mAP只下降约0.1到0.3个百分点。如果想在CPU上部署模型,这个技巧非常实用。

上面这些问题想清楚了,你对Fast R-CNN的理解就已经超过很多人了。接下来进入动手环节。

4. 环境搭建与数据准备

4.1 依赖环境与版本选择

复现Fast R-CNN,第一件事不是写代码,而是把环境理清楚。因为这是一个2015年的模型,现在的深度学习框架版本差异很大,如果直接拿最新的PyTorch跑老代码,大概率会遇到API不兼容的问题。我给出一套我在实践中验证过比较稳妥的组合:

组件推荐版本说明
Python3.8/3.93.10以上某些老库可能编译失败
PyTorch1.10~1.131.x系列API稳定,对RoI Pooling支持好
torchvision0.11~0.13对应PyTorch版本的官方扩展
CUDA11.3左右与PyTorch版本匹配即可
OpenCV4.x图像读取、候选框可视化
数据集PASCAL VOC 2007/2012经典检测数据集,训练验证方便

有人可能会问,直接用MMDetection这类工具箱复现不就行了?我的回答是:可以,但我不建议你一上来就用。MMDetection把Fast R-CNN封装得太好,你很难感受到核心模块到底是怎么串起来的。我建议第一次复现至少手动写一遍数据加载、RoI Pooling和损失计算,哪怕跑得慢一点,理解深度完全不一样。当然,你完全可以用开源库的RoI Pooling实现(比如torchvision.ops.roi_pool),没必要从零写CUDA算子。

4.2 数据集准备与标注格式说明

PASCAL VOC是一个经典的目标检测数据集,2007版有9963张图片,标注了20个物体类别,包括人、鸟、猫、牛、狗、马、羊、飞机、自行车、船、巴士、汽车、摩托车、火车、瓶子、椅子、餐桌、盆栽植物、沙发、电视。每张图片对应一个XML文件,记录了每个目标的类别和边界框坐标。

XML标注格式大致是这样的:

<annotation> <filename>000001.jpg</filename> <size> <width>500</width> <height>375</height> </size> <object> <name>dog</name> <bndbox> <xmin>100</xmin> <ymin>50</ymin> <xmax>300</xmax> <ymax>250</ymax> </bndbox> </object> </annotation>

下载数据集后,目录结构应该是这样的:

VOCdevkit/ └── VOC2007/ ├── Annotations/ # 存放XML标注 ├── ImageSets/ │ └── Main/ # 存放train.txt、val.txt等划分文件 ├── JPEGImages/ # 存放JPG图片 ├── SegmentationClass/ └── SegmentationObject/

在训练前,我们需要把XML标注解析成模型可用的格式,通常是一个字典或张量列表。这里的关键一步是生成候选区域。Fast R-CNN自己不做候选区域生成,你需要额外用Selective Search产生。在复现中可以直接用selectivesearch这个Python库:

import selectivesearch import cv2 img = cv2.imread('000001.jpg') img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) _, proposals = selectivesearch.selective_search( img_rgb, scale=500, sigma=0.9, min_size=10) # proposals是多个候选框字典,提取坐标即可 candidate_boxes = [] for prop in proposals: x, y, w, h = prop['rect'] candidate_boxes.append([x, y, x + w, y + h])

这里要注意,Selective Search生成的候选框数量通常在1000到2000个左右,Scale参数越大候选框越多但计算量也越大。训练时我们用2000个,测试时用2000个,保持一致性。

4.3 数据加载器的设计思路

训练Fast R-CNN时,我们不是把一张图的全部候选框都塞进一个batch,而是精心挑选。原论文的做法是:一个mini-batch包含2张图像,每张图像取64个RoI,其中正样本(与某个标注框IoU大于等于0.5)最多占25%,也就是16个,其余48个为负样本(IoU在0.1到0.5之间)。为什么要这样做?因为如果负样本太多,模型会严重偏向背景类,导致检测不到物体。这个数据均衡策略在检测任务里是基本功,后续几乎所有两阶段检测器都在沿用。

我自己写数据加载器时的代码逻辑是:

def load_train_batch(img_path, rois, gt_boxes, gt_labels): # rois是候选框,gt_boxes是真实标注框 # 计算所有候选框与真实框的IoU ious = compute_iou(rois, gt_boxes) # 每个候选框分配一个真实框标签(找最大IoU的那个) max_iou, max_idx = ious.max(dim=1) labels = gt_labels[max_idx] labels[max_iou < 0.5] = 0 # 背景类设为0 # 挑选正负样本,保证正样本比例 pos_idx = (max_iou >= 0.5).nonzero() neg_idx = (max_iou < 0.5).nonzero() ...

这里有个细节值得注意:负样本的IoU上限一般设在0.5以下,但如果IoU介于0.1到0.5之间,视为“难负样本”,保留下来训练会更好。如果IoU低于0.1,说明候选框和任何真实目标都几乎不重叠,这种样本太简单,对训练帮助不大,干脆直接丢弃。这个“难负样本挖掘”的思想在现代检测器里也非常常见。

5. 完整复现流程与核心代码

5.1 构建Fast R-CNN网络结构

Fast R-CNN的主干网络可以是AlexNet、VGG16,甚至是ResNet。原论文实验里最强的是VGG16。我们来搭建一个简化版的Fast R-CNN,主干用VGG16的卷积部分,后面接RoI Pooling和两个全连接分支。

import torch import torch.nn as nn import torchvision.ops as ops class FastRCNN(nn.Module): def __init__(self, num_classes=21): super().__init__() # 使用VGG16的特征提取部分(到conv5为止) from torchvision.models import vgg16 vgg = vgg16(pretrained=True) self.backbone = nn.Sequential(*list(vgg.features)[:30]) self.stride = 16 # RoI Pooling的输出尺寸 self.roi_size = 7 # 经过RoI Pooling后,特征维度 = 512 * 7 * 7 self.fc6 = nn.Linear(512 * 7 * 7, 4096) self.fc7 = nn.Linear(4096, 4096) self.cls_score = nn.Linear(4096, num_classes) self.bbox_pred = nn.Linear(4096, num_classes * 4) self.relu = nn.ReLU(inplace=True) self.dropout = nn.Dropout(p=0.5) def forward(self, x, rois): # x: [B, 3, H, W] 输入图像 # rois: [N, 5] 每个值是 (batch_index, x1, y1, x2, y2) features = self.backbone(x) # [B, 512, H/16, W/16] # RoI Pooling pooled = ops.roi_pool(features, rois, output_size=(self.roi_size, self.roi_size), spatial_scale=1.0 / self.stride) # pooled: [N, 512, 7, 7] pooled = pooled.flatten(start_dim=1) fc6_out = self.dropout(self.relu(self.fc6(pooled))) fc7_out = self.dropout(self.relu(self.fc7(fc6_out))) cls_scores = self.cls_score(fc7_out) bbox_deltas = self.bbox_pred(fc7_out) return cls_scores, bbox_deltas

这段代码里需要特别留意的是roi_pool输入的坐标系。PyTorch的ops.roi_pool要求rois的坐标是相对于输入图像的绝对坐标,内部会根据spatial_scale参数做缩放。这里spatial_scale=1/16是因为VGG16卷积部分的总步长是16,也就是说特征图上的一个像素对应原图16个像素。

另外注意bbox_pred的输出维度是num_classes * 4,也就是每个类别都预测一组偏移量。为什么要对每个类别单独预测?因为不同类别的物体,长宽比和偏移模式差异可能很大,比如“人”通常是窄高的,“汽车”通常是宽扁的。让模型为每个类别学习独立的回归参数,可以提升定位精度。实际使用时,我们只需取预测概率最大类别对应的那组偏移量即可。

5.2 损失函数实现与训练循环

损失函数的实现直接对应论文里的公式。分类部分用交叉熵,回归部分只计算正样本。

def fast_rcnn_loss(cls_scores, bbox_preds, labels, bbox_targets, num_classes=21, lambda_reg=1.0): # cls_scores: [N, 21] # labels: [N] 每个RoI的类别标签,背景为0 # bbox_preds: [N, 84] # bbox_targets: [N, 84] 只对正样本位置有效 # 分类损失 cls_loss = nn.functional.cross_entropy(cls_scores, labels) # 回归损失:只对非背景样本计算 pos_mask = labels > 0 if pos_mask.sum() > 0: # 为每个样本选择对应类别的回归预测 rois_batch = torch.arange(labels.shape[0], device=labels.device) selected_bbox_pred = bbox_preds[rois_batch, labels * 4:(labels + 1) * 4] selected_bbox_target = bbox_targets[rois_batch, labels * 4:(labels + 1) * 4] reg_loss = smooth_l1_loss(selected_bbox_pred, selected_bbox_target) else: reg_loss = torch.tensor(0.0, device=cls_scores.device) total_loss = cls_loss + lambda_reg * reg_loss return total_loss

有几个实现细节我想特别强调。

第一,bbox_targets在数据准备阶段怎么算。给定候选框(x, y, w, h)和它匹配的真实框(x*, y*, w*, h*),偏移目标的计算遵循我在3.2节给的公式。这些目标要在训练前预先算好,注意只对正样本填充有效值,负样本位置可以全填0,反正回归损失不会计算它们。

第二,SVD加速在训练时不需要用,只在推理时把fc6和fc7替换成两个小矩阵相乘即可。训练时正常反向传播,SVD会破坏梯度的连贯性。

第三,学习率设置。原论文用SGD,初始学习率0.001,每经过一定迭代轮次后降低为原来的十分之一。批量大小2张图,每张图64个RoI,相当于一个batch有128个RoI参与训练。如果显存不够,可以适当减少RoI数量到32,但精度会有轻微下降。

5.3 训练中的关键参数与技巧汇总

我把复现过程中需要重点关注的参数整理成表格,这些不是我拍脑袋编的,都是从论文实验和实际调试中得到的经验。

参数推荐值说明
主干网络VGG16特征能力强,但显存占用大
RoI Pooling输出尺寸7×7在精度和计算量之间平衡
每张图RoI数量64论文标准设定
正负样本比例1:3保证正样本不至于过少
初始学习率0.001微调预训练主干时常用
学习率衰减每几轮降10倍让损失后期稳定下降
动量0.9SGD标配
权重衰减0.0005防止过拟合
迭代轮数30k~40k视数据集大小调整
类别数21VOC是20类+背景

我实际调试时有个体会:不要一上来就追求完整复现论文的每一点细节,先把VOC2007的train集跑通、mAP跑到60以上,再逐步调整数据增强、学习率策略和RoI采样比例。先跑通再调优,这条路径比一开始就想全部拉满稳得多。

5.4 推理流程与可视化检测结果

训练完成后的推理流程和训练时很相似,但有一些细节不同。完整流程是这样的:读入图片,用Selective Search生成候选框,整图过一遍网络提取特征,对每个候选框做RoI Pooling,再过全连接层得到类别分数和边框偏移。此时对每个候选框,先根据预测的偏移量修正坐标,然后用NMS(非极大值抑制)去掉重叠的框。NMS的阈值一般设在0.3到0.5之间,越高保留的框越多,越低去除越狠。

def infer(net, img, proposals, conf_thresh=0.5, nms_thresh=0.3): net.eval() with torch.no_grad(): # 图片预处理、转tensor等略 img_tensor = preprocess(img) rois = torch.tensor(proposals, dtype=torch.float32) cls_scores, bbox_deltas = net(img_tensor.unsqueeze(0), rois) probs = torch.softmax(cls_scores, dim=1) # 对每个类别做NMS,只取置信度大于阈值的 final_boxes = [] for cls_id in range(1, net.num_classes): mask = probs[:, cls_id] > conf_thresh if mask.sum() == 0: continue cls_boxes = proposals[mask] cls_scores_cur = probs[:, cls_id][mask] # 利用预测的bbox_deltas修正候选框 cls_deltas = bbox_deltas[mask, cls_id * 4:(cls_id + 1) * 4] refined_boxes = apply_deltas(cls_boxes, cls_deltas) keep = nms(torch.tensor(refined_boxes), cls_scores_cur, nms_thresh) ... return final_boxes

这里NMS的输入特别容易写错。NMS的目的是去掉“同一个目标上的重复框”,所以必须按类别分别做,不能把所有类别的框放在一起做。否则一个行人和一辆汽车如果靠得近,可能互相压制导致漏检。我在第一次写的时候就是没按类别分开NMS,结果明明检测到了目标,可视化时却发现很多框不见了,排查了半天才找到这个原因。

关于置信度阈值和NMS阈值怎么选,我给一组经验值。在VOC数据集上,置信度阈值设在0.5,NMS阈值设在0.3,通常能得到比较干净的可视化结果。但要注意,这组参数只是视觉效果友好,并不一定对应最高mAP。做评估时,我们要用更严谨的mAP计算流程,这个放到下一节讲。

6. 常见问题与排查技巧实录

6.1 训练显存溢出

跑Fast R-CNN最常见的问题就是显存溢出,尤其是用VGG16主干。VGG16的卷积层中间特征图非常大,一张640×480的图片经过VGG16前几层后,通道数128时特征图尺寸还是320×240,一张图就要占用不少显存。再加上每个batch两张图、每张图64个RoI,显存消耗很容易爆。

我的排查思路是这样的:先降到单图、单RoI测试显存占用,逐步加大看哪一步消耗最多。如果瓶颈在主干卷积,可以减小输入图片的短边尺寸,比如从600缩到500。如果瓶颈在RoI Pooling后的大量RoI并行计算,可以减少每张图的RoI数。还可以把torchvision.ops.roi_pool换成RoIAlign,它的显存占用更低,精度还更高,一举两得。

如果显存实在不够,最后的手段是用梯度累积。即每轮只处理少量样本,梯度先累积几个batch再统一更新参数。虽然训练时间变长,但至少能跑起来。

6.2 分类收敛但回归不收敛

这是一个非常隐蔽的问题。如果你发现分类损失掉得很快,但回归损失怎么也降不下去,先别急着调学习率,检查一下bbox_targets的计算是不是有问题。最容易被忽视的是坐标归一化:t_x和t_y是用原图宽度和高度归一化,t_w和t_h是对数缩放。如果代码里把宽高搞混了,回归目标就会出现系统性错误。

另外,回归分支的输出层通常不接激活函数,直接线性输出。如果你在bbox_pred后面加了ReLU或Tanh,会限制输出范围,导致模型学不出大的偏移量。我在调试时见过有人把全连接层的初始化默认值改成了0,结果回归分支的梯度直接消失,损失纹丝不动。检查一下这些基础细节,比盲目调参有效得多。

6.3 mAP评估时容易踩的坑

最后说一下评估指标的问题。热词里反复出现“目标检测评价指标”“红外小目标检测中的一些评价参数”,说明大家都很关心怎么算mAP。我这里讲三个最核心的点。

第一,mAP计算需要按类别分别统计。对每个类别,先把所有测试图片上该类的检测结果按置信度从高到低排序,然后逐一计算precision和recall,画PR曲线,曲线下的面积就是该类的AP。20个类的AP取平均就是mAP。这个计算过程代码不算复杂,但要小心排序和去重的细节,建议直接用标准工具库,别自己造轮子。torchvision.ops.box_iou和pycocotools都可以处理。

第二,IoU阈值不同,mAP数值完全不一样。VOC用的IoU=0.5,COCO用的是一系列从0.5到0.95的IoU阈值取平均。所以你在比较不同模型的mAP时,一定要先确认评估协议是否一致。Fast R-CNN论文里的70.0%是基于VOC协议,拿去和COCO协议的mAP对比没有任何意义。

第三,评估时要不要做NMS?要,但是NMS对最终mAP影响比较微妙。NMS阈值设得太低,重叠度大的同类别目标会被抑制掉,召回率下降,AP降低。NMS阈值设得太高,会出现大量重复框,precision下降。在VOC上,0.3到0.5之间都有合理表现。如果你发现某个类别的AP特别低,先单独可视化一下这个类别的检测结果,看看是漏检还是重复框太多,再决定调阈值还是调模型。

我个人的习惯是,先不调任何后处理参数,用论文默认的conf_thresh=0.05、nms_thresh=0.5跑一遍,观察各类别AP分布。然后再根据各类问题做针对性调整。这么做的好处是,你可以把模型能力和后处理调优分开看待,不会把两者混为一谈。

6.4 快速排查速查表

为了方便你排查问题,我把常见症状和对应思路整理成了一张表。

症状可能原因排查与解决
损失一直不降学习率过大/过小尝试0.01、0.001、0.0001对比
分类正常但回归损失高bbox_targets计算错误检查坐标归一化、宽高取对数逻辑
检测框位置偏RoI坐标映射错误检查spatial_scale是否与主干步长一致
mAP过低但训练损失正常评估协议不一致确认IoU阈值、类别顺序、NMS逻辑
推理速度慢全连接层计算量大用SVD分解或减小RoI数量
显存溢出batch过大/图片过大缩小输入尺寸、减少RoI、用梯度累积
检测大量重复框NMS阈值太高或未按类NMS降低阈值、确认按类别分别处理

这表里的经验,基本覆盖了我自己复现过程中遇到的大部分问题。当然,代码版本不同、数据不同,具体表现会有差异,但排查思路是通用的。遇到问题先聚焦在“数据流对不对”上,再看“模型结构对不对”,最后才会怀疑“优化参数行不行”,这个顺序能帮你少走很多弯路。

尾声:一点实际操作后的体会

Fast R-CNN这个模型,说实话在今天已经不是最佳实践了,你真正上线做检测大概率会选Faster R-CNN、YOLO或者基于Transformer的DETR系列。但我依然建议你有时间就手动复现一遍。因为它是理解两阶段检测器最合适的“最小完备系统”:候选区域、特征图共享、RoI Pooling、多任务损失、NMS,这些概念在几乎所有的现代检测器里都会以不同形式出现。你把这个模型吃透了,后面看Faster R-CNN就是加一个RPN、看Mask R-CNN就是加一个分割分支,学习曲线会平缓很多。

回看我自己的复现过程,最大的收获不是把mAP跑到67%的成就感,而是通过手动调参,真正理解了为什么候选区域的采样比例要设1:3、为什么回归损失要用smooth L1、为什么NMS要按类别处理。这些经验是读一百篇论文都换不来的。希望这篇博文能帮你少踩几个坑,也能让你在动手的时候,心里多一份笃定。

返回列表