学目标检测的人应该有很长一段时间都在跟Faster R-CNN打交道。以前我用Caffe版本跑通了一套检测流程后来导师让我把整套东西迁移到PyTorch上我就选了当时比较稳定的Faster RCNN pytorch 1.0版本作为基础在复现和调参的过程中前前后后踩了将近一个月的坑。这篇文章就是一次完整的Faster RCNN pytorch 1.0调试过程记录把我遇到的环境配置、代码实现、训练复现、推理验证这些环节里的问题全部摊开来讲尽量让后来的人少走一点弯路。我默认看这篇记录的人已经对深度学习和卷积网络有基本了解但如果只是刚开始接触检测方向也不用怕我会把涉及到的原理和参数用白话再解释一遍。整个调试过程我分成了几个大块来回顾环境搭建、核心模块的代码核对、训练阶段的异常排查、推理阶段的表现分析。每个部分里我都会附上自己实际的报错信息、当时的排查逻辑和最终解决办法有些坑现在看起来很简单但当时确实花了很久才定位到根因。1. 动手调试前先把这个模型的全链路想清楚1.1 Faster R-CNN并不是一个黑盒完整数据流梳理Faster R-CNN是典型的二阶段检测器整个数据流可以拆成四段特征提取、候选框生成、区域特征聚合、分类回归。我当时调试的前提条件是采用ResNet-50作为骨干网络输入图像先被resize到较短边800像素、较长边不超过1333像素然后经过骨干网络下采样最终得到相对于原图1/16尺寸的特征图。这个特征图就是RPN和后续检测头的共同输入。重点说一下RPN这层它是Faster R-CNN最核心的模块。RPN在特征图每个位置生成一组不同尺度和长宽比的anchor常见的配置是3种尺度128、256、512和3种长宽比0.5、1.0、2.0所以每个位置有9个anchor。对于一张800x1333的输入经过16倍下采样之后特征图是50x83那么总的anchor数量就是50x83x9大概三万七千个左右。这个数量级在GPU上做IoU计算是完全没问题的但如果你在CPU上尝试调试就会明显感受到瓶颈我第一次在只有8G内存的机器上跑前向直接卡了四五分钟后来才发现问题不在模型本身而是我用了过多的CPU进程去算IoU。从RPN出来之后模型需要从三万个anchor中筛选出大小合适的候选框然后送入ROI Pooling层。在PyTorch 1.0版本的常见实现中这里有几种选择可以直接调用torchvision.ops里的RoIPool也可以自己写甚至有人直接用ROI Align。我在调试中发现很多旧代码仓库使用的是自己实现的ROI Pooling接口非常奇怪输入是feature map和roi boxes拼接后的tensor非常容易在维度上踩坑。我建议初学者在看到这类代码时先花半小时把整个forward的数据维度变化写在纸上不要直接硬跑不然报错会让你怀疑人生。1.2 PyTorch 1.0这个版本节点的特殊性PyTorch 1.0是PyTorch进入稳定版的标志性版本2018年底发布的和之前的0.x系列相比它把原先分散的模块整合起来引入了torch.jit和更稳定的分布式接口。但问题也随之而来很多在0.4、0.3上能跑的代码到了1.0就会报错因为很多API的调用方式变了比如Variable、Tensor的合并还有一些函数从torch.nn.functional迁移到了torchvision.ops。我用的Faster RCNN pytorch 1.0版仓库是当时很流行的几个开源版本之一但这个版本本身基于的PyTorch版本并不是纯粹的1.0它可能用了一些更早期的API写法。我在初次运行的时候光是import就报了好几个AttributeError比如torch.Tensor.new_tensor这类方法在某些版本里面根本不存在。所以说调试这个项目的第一步不是改模型而是把代码和当前的框架版本做一次全面适配搞清楚哪些是框架原生API哪些是仓库自己封装的方法。2. 环境配置阶段的那些坑版本搭配比想象中更致命2.1 CUDA、cuDNN、PyTorch、torchvision的对应关系如果你在配置环境的时候随便装那大概率会遇到“非法指令”“找不到CUDA driver”“段错误”这类问题。我的第一台调试机装的是CUDA 11.2和cuDNN 8.1然后强行把PyTorch 1.0对应的代码用PyTorch 1.7的环境跑结果一进forward就报错后来发现是部分算子不兼容。这里我必须整理一个我自己实测过相对稳定的组合供参考组件版本搭配建议Python3.7PyTorch1.0.0或1.1.0torchvision0.2.1或0.3.0CUDA9.2或10.0cuDNN7.4及以上依赖库numpy 1.16、opencv-python 3.4、matplotlib 3.1我说的“相对稳定”不代表完全没有坑比如CUDA 9.2和cuDNN 7.6.5的组合在我的机器上可以正常运行但换到另一台相同型号的机器上就会出现随机性的段错误。后来查到是cuDNN的缓存机制在某些算子上有问题解决办法是把环境变量TORCH_CUDA_ARCH_LIST设置成和你GPU算力一致的版本再重新编译。比如我的显卡算力是7.5就设置成7.5很多时候这个设置能解决莫名其妙的报错。2.2 搭建可复现环境的具体步骤由于这个项目太老我建议不要直接用最新版的conda去创建环境而是用requirements.txt锁定版本。先把Python环境建好conda create -n frcnn python3.7 conda activate frcnn pip install torch1.0.0 torchvision0.2.1 pip install numpy1.16 opencv-python3.4.2.17 matplotlib3.1.0 pillow7.0.0这里有一个小坑PyTorch 1.0的安装包在pip源里的依赖很严格如果提前装好了numpy 1.20那安装torch的时候会被自动升级导致版本冲突。所以我的经验是先创建一个全新的环境然后一次性把torch和torchvision装上再装别的依赖不要分批次操作不然很容易出现依赖错乱。编译ROI模块这一步也很关键老版本仓库里通常都有一个setup.py需要先执行python setup.py build develop这个命令的作用是把项目里的C/CUDA扩展编译好并链接到当前Python环境中。我遇到最多的问题是在这一步缺少gcc或者CUDA toolkit没有安装完整导致编译直接失败。常见的报错是找不到cuda_runtime.h这时候就要检查一下你的CUDA_HOME环境变量是不是正确指向了CUDA安装目录。2.3 配置阶段三个隐蔽错误第一个隐蔽错误是设错了torchvision的版本很多人使用pip install torchvision自动装结果装上的是新版0.5和新版的PyTorch可以互通但和1.0的接口不匹配一段代码都跑不起来。第二个是忽略了下采样倍率的定义有些仓库使用的骨干网络是VGG16不是ResNet它的下采样倍率是1/16但如果换成其他网络就必须修改RPN和ROI相关的空间比例参数不修改的话anchor位置全歪训练也不收敛。第三个是路径配置问题数据集路径、预训练权重路径里面的中文目录或者特殊符号会导致读取失败我在一个同事的机器上见过因为Windows用户名带了中文字符导致整个数据加载崩溃的情况。3. 代码实现里的核心模块与高频报错点3.1 RPN层的anchor生成与标签分配逻辑在我这个调试版本中RPN的anchor生成是在一个独立的py文件里写的使用base_size16、ratios[0.5, 1, 2]、scales[8, 16, 32]。这里注意scales的值和我在前面说的128、256、512其实用的是不同的表达方式前者表示在基础框上的乘数后者是实际边长换算关系是128/168256/1616512/1632。很多新手在这里会对不上然后设置调试断点看anchor的形状是没问题的但和GT计算IoU的时候坐标偏移却完全对不上这就是scale表达方式没弄清楚。在RPN进行标签分配时需要给每个anchor打上正样本、负样本和忽略样本的标记。正样本的判定条件是IoU大于0.7或者是该anchor与某个GT的IoU最大负样本是IoU小于0.3介于中间的忽略。之后再从正负样本中采样保证一个batch里正负样本数量总和为256如果正样本不足256就用负样本补足。实际调试中我发现很多仓库在实现“某个anchor与GT的IoU最大”这一逻辑时是用了两个argmax来做的一个沿着anchor维度找每个GT的最大IoU索引一个沿着GT维度找每个anchor的最大IoU。如果这两个argmax的顺序写反了就会导致大量正样本丢失训练时RPN的loss会一直下降得很慢最终的proposal质量很差。3.2 特征图尺寸对齐与ROI Pooling的维度地狱ROI Pooling是整个模型中最容易出维度错误的地方。我当时用的Polygon库版本ROI Pooling的输入是特征图加上所有ROI的坐标输出为固定尺寸例如7x7的特征。这里有个关键问题ROI坐标是基于原始图像尺寸的还是基于特征图尺寸的两个仓库可能采用完全不同的约定。我在这个项目里遇到过一次运行时错误RuntimeError: invalid argument 2: size ‘7 x 7 x 512’ is invalid for input with 10240 elements这个报错让我一度以为batch size设置错了后来查了很久才发现是把一个batch的ROI数量当成了单个图像的ROI数量导致维度变成了512倍正确做法是把这个维度在forward之前先展开。还有一点是ROI Pooling的spatial_scale参数这个参数表示原图坐标到特征图坐标的缩放因子。如果用1/16下采样的骨干网络这个值就是0.0625。有次我把这个值写成了1.0跑起来没有报错但是训练出来的模型检测框位置完全不对变成了所有框都集中在图像的左上角。从这里我学到一句话不报错的错误往往比报错的错误更可怕坐标变换类的bug必须用可视化验证。3.3 边界框回归的编码与解码目标检测最后回归的不是边界框的绝对坐标而是相对于anchor的偏移量这种编码方式在PyTorch实现中有非常明确的公式tx (x - xa) / wa ty (y - ya) / ha tw log(w / wa) th log(h / ha)公式里x、y分别代表GT框中心点坐标xa、ya、wa、ha是anchor的坐标和尺寸。回归网络的输出就是对这四个偏移量的预测预测之后需要经过解码得到最终的预测框。我在调试中发现解码部分的实现最容易出现的问题是坐标变换后没有做边界截断导致预测框超出图像范围在训练时不明显但在测试阶段会造成mAP异常低。建议大家在调试时打印一下预测框解码前后的坐标范围一般如果解码前的tx在-1到1之间解码后的框应该不会跑出图像太远如果出现几十甚至上百的坐标偏移基本可以确定是编码公式写错了。我调试过程中还踩过一个很隐蔽的坑就是网络的回归输出没有经过sigmoid或者tanh限制导致tw无限大解码之后框的宽高爆炸loss直接为NaN。3.4 损失函数与样本采样的隐藏问题Faster R-CNN包含两个损失RPN的分类和回归损失以及RCNN的分类和回归损失。常见的实现是分类用交叉熵回归用smooth L1。我在代码里排查的时候发现很多实现会在计算RCNN回归loss时只对正样本进行计算而负样本的回归值直接忽略这一点一般不会写错但容易出现的问题是正样本数量为0时回归损失分母为0导致除零错误。这个问题在训练前期尤为常见因为随机初始化的RPN生成的proposal质量很差可能一个正样本都没有。稳妥的做法是在计算损失之前判断正样本数量如果为0就返回一个零损失。我当时加了这样一个分支之后训练稳定了很多虽然看起来只是几行代码但解决了一个非常核心的稳定性问题。这里也提醒一点损失函数是所有梯度汇聚的终点任何一个分支的不稳定都会直接影响整个模型调试的时候一定要保持“最小化影响原则”改一处、测一处。4. 训练过程那些让人想砸电脑的异常排查4.1 loss不下降、直接爆成NaN从哪里入手我第一轮训练遇到的最典型问题是loss在第一个epoch后就变成了NaN。这个问题的排查链路其实有先后顺序先看数据再看学习率再看损失函数最后看梯度。按这个顺序走能省很多时间。数据侧主要是检查GT框是否存在面积为0的框、图片是否存在全黑或者全白的异常样本、标注文件里是否有超出图像边界的坐标。我那次排查发现数据集里有几张图标注文件用的是左上角坐标和右下角坐标但是有一张的右下角比左上角还小导致宽高为负数在计算IoU时出现了log(负数)的NaN。把这几条标注清洗掉之后NaN就消失了。学习率侧我当时用的是0.02的初始学习率配合一个10倍衰减的step策略。但在PyTorch 1.0版本不同优化器的默认权重衰减和动量设置可能差异很大特别是SGD的momentum默认是0很多人会忽略这个导致模型在初期不稳定。我建议新手先用SGDmomentum0.9weight_decay5e-4学习率从0.005开始逐步往上调不要一上来就追求0.02这种大学习率。还有一个被我忽略很久的点是BN层。如果使用ResNet-50作为骨干网络并且加载了在ImageNet上预训练的权重那么BN层的running_mean和running_var是已经冻结好的。但在PyTorch的默认训练模式下这些BN层仍然会被更新如果你使用的数据集和目标域差异巨大BN的统计量会迅速偏移导致loss爆炸。解决方法是把骨干网络的前几层在训练时固定住或者在加载预训练权重后对BN层设置requires_gradFalse。我实测之后前者的效果更稳定。4.2 显存溢出OOM的排查与缓解方案我在调试时因为图像尺寸是800x1333一张图大概占800MB左右的显存batch size一设为4就显存爆了。排查方法很简单先用pytorch的torch.cuda.max_memory_allocated打印当前占用然后逐步减小batch size直到能运行再通过观察占用来估算模型的显存费用。除了降低batch size我实际采用的方法还有梯度累积就是把4个batch的梯度累积成一个batch再更新这样可以在不降低有效训练规模的情况下减少显存占用。PyTorch实现梯度累积的做法很直接在循环里累计梯度每N个iter才更新一次参数注意别忘了除以N来缩放梯度。显存泄漏也是训练中会遇到的坑表现是跑着跑着显存占用越来越高最后OOM。我排查下来原因通常是DataLoader的num_workers设置不当或者某些tensor没有及时释放。后来我在每个epoch结束之后调用torch.cuda.empty_cache()显存占用就稳定下来了。4.3 GPU利用率低的定位方法训练速度慢不一定全是模型复杂很多情况是GPU在空转。我用nvidia-smi观察GPU利用率发现只有30%左右说明数据加载是瓶颈。问题一般出在DataLoader的num_workers设太小、没有开pin_memory、以及预处理时用到了CPU上的opencv操作。我的调整方案是把num_workers设为4具体看CPU核数、pin_memoryTrue同时把数据预处理resize、normalize尽量放到GPU上做比如用torchvision的transforms或者是CUDA tensor的归一化操作。经过这一轮优化GPU利用率从30%提升到了80%以上。还有一个小技巧是如果训练数据量特别大建议先把图片解码成numpy数组保存到内存中在dataloader里直接读内存省去磁盘IO。我踩过的坑是如果图片存在机械硬盘那数据加载时间会占整个训练时间的70%这种情况下不管怎么调num_workersGPU利用率都上不去唯一的解法是把数据拷贝到固态硬盘或者是放到内存里。4.4 断点续训时踩过的坑模型训练到一半断掉是常事所以我提前写好了checkpoint保存和恢复的逻辑。我保存的checkpoint包含模型权重、优化器状态、epoch和当前的学习率这样恢复之后可以无缝衔接。但这里有个坑PyTorch不同版本保存的checkpoint格式不完全兼容我遇到过用PyTorch 1.0保存的权重在1.7环境下加载部分key出现了mismatch后来统一用同一版本的环境来续训。还有一个容易被忽略的地方是恢复训练的时候要把随机种子重新设置不然数据增强的随机性会完全打乱前几个epoch相当于重新开始损失会有一次很大波动。我自己写了个简单的seed设置函数在恢复时重新调用即可。5. 推理与验证阶段的调试心得5.1 NMS阈值对结果的影响有多大推理的时候模型会输出大量冗余的检测框需要用NMS来去重。NMS有个核心参数是IoU阈值阈值越高保留的框就越多检测结果越精细但容易出现多个框重叠阈值越低去重越狠但可能会把两个相邻的目标当成一个。我在调试时用0.5和0.7分别测了mAP发现0.5阈值下的检测框更密集但很多是假阳性0.7阈值下mAP更高尤其对于小目标更友好。另外还要注意PyTorch 1.0自带的torchvision.ops.nms在3D tensor上可能不支持所以如果你处理的是视频流需要自己把每一帧循环调用。这种推理阶段的问题定位要依靠可视化我自己写了一个可视化脚本把GT框和预测框同时画出来这样能直接看出问题是出在回归精度不高还是分类置信度不够。5.2 mAP的计算细节与自查方法mAP计算是整个检测流程里最容易被低估的部分。不同的实现会用不同的IoU阈值、不同的匹配规则导致结果相差很大。我使用的评估脚本主要参考COCO的评估方式对每个类别分别计算AP然后平均得到mAP。调试时我发现很多人的mAP很低不是模型训练不好而是评估脚本里在匹配检测框和GT框时没有按照置信度从高到低排序导致匹配失败。我建议评估的时候把每个类别的PR曲线画出来这样能清晰地看到阈值对精度和召回率的影响比单纯看一个mAP数字有用得多。还有一个坑是要把“置信度为0”的检测框从评估中过滤掉不然它们会拉低精度。我在评估脚本中加了一个过滤条件置信度小于0.05的框直接跳过mAP立即提升了2个点。5.3 可视化调试的利器在不熟悉模型输出结构时最简单的可视化是打印tensor的shape、mean、std。但更高阶的做法是利用TensorBoard来记录loss曲线和梯度范数这样能直观看出模型是否收敛。my项目里我用的是visdom虽然它比较老但在PyTorch 1.0时代还是很好用的。如果你也想快速在训练时看看特征图可以把特征图的数值缩放到0-255之间再用matplotlib画成热力图我能从热力图中看到网络是否存在“特征响应集中在某一个区域”的问题这个信息对调整损失权重很有用。后来我还加了一个中间层输出的可视化在调RPN网络时非常有效能直接看到anchor在特征图上的分布是否合理。6. 最后的经验沉淀调试Faster R-CNN该有的心态和顺序我个人调试完这个项目最深刻的体会是Faster R-CNN的调试不是找单一bug而是不断试错并建立一套“现象-原因”的映射关系。遇到一种报错先记录下环境、输入、报错和你的处理方式下次再遇到就可以直接秒杀。在心态上我想建议大家准备一定的心理预期这个模型的调试周期不会太短尤其是从零开始训练的话至少需要跑十几个epoch才能看到明显的效果。不要因为loss在前两个epoch没降就怀疑模型结构检测模型的收敛速度要比分类模型慢得多。在顺序上如果你想要快速复现建议先加载预训练权重然后在预训练基础上微调这样比从随机初始化开始训练要靠谱得多。我最后稳定下来的方案是预训练权重加载ResNet部分然后冻结前两个stage只用后面几个stage和RPN、RCNN头去训练。这个方法在小数据集上的表现非常好在完整的数据集上也能在一个到两个epoch内就开始收敛。这篇调试记录写到这也就差不多了都是我一步步试出来的结果。最后再分享一个小技巧每次改一个参数或者一段代码都把当时的loss曲线截图保存下来过几天再回来看你会发现很多当时觉得离奇的问题其实早就有迹象只是你没有记录也就没有引起注意。祝大家调参顺利少踩坑。