很多同学第一次打开 mmDetection 的仓库,都有一种“从入门到放弃”的冲动:mmdet/models目录下躺着backbones、necks、dense_heads、roi_heads一堆文件夹,点进configs发现每份配置文件长得像一棵树,尤其model字段,嵌套七八层,里面全是dict。我当年第一次看 Faster R-CNN 的配置时,脑子里只有一个念头:这玩意儿到底是怎么变成一个能跑的网络的?
这篇笔记想解决的,就是这个问题。我会沿着 mmDetection 的网络架构这条线,把model配置里的每个字段掰开揉碎,告诉你 backbone、neck、head 各自扮演什么角色,它们怎么组合成一个完整的检测器,以及如果你想把某一层换掉、加一个模块,应该改哪里、怎么改。适合谁看?刚接触 mmDetection、跑通了官方 demo 但对配置一脸懵的科研小白;以及在框架里做过小改动、却始终没弄清“为什么这样写”的开发者。
1. 先建立整体印象:mmDetection 的网络分层思想
1.1 检测器不是一个黑盒,而是一条流水线
目标检测和图像分类最大的区别,在于它要同时回答两个问题:物体在哪里(定位)和物体是什么(分类)。分类任务可以干脆利落地拿一个网络把图像映射成类别概率,但检测任务不行——你既要提取特征,又要在特征上做密集的候选框预测,还要对候选框做精修,这么多事不可能靠一个单一的“黑盒网络”完成。
mmDetection 的做法,是把这条链路拆成几个职责清晰的模块,典型结构是:
- Backbone(主干网络):负责从原始图像中提取多层次特征。
- Neck(颈部模块):在 backbone 输出的多尺度特征上做融合或增强,最典型的就是 FPN。
- Head(检测头):在融合后的特征上做最终的分类和回归预测。双阶段检测器还会拆成 RPN Head 和 RoI Head。
- Detector(检测器外壳):把上面这些模块拼装起来,同时负责训练和推理流程的编排。
这个设计思路很像工厂里的流水线:原料(图像)先进第一道工序(backbone)粗加工,再进第二道工序(neck)做精细处理,最后由检测头(head)完成质检和打包。生产线上的每个环节都可以独立替换,这也是 mmDetection 能支持几十种检测器的根本原因。
1.2 你一定会见到的几个“角色”字段
翻看任意一份 mmDetection 配置,model字段下常见的关键字就那么几个:
model = dict( type='FasterRCNN', backbone=dict(...), neck=dict(...), rpn_head=dict(...), roi_head=dict(...), )单阶段检测器(比如 RetinaNet、YOLO 系列)通常只有backbone、neck、bbox_head,没有rpn_head和roi_head,因为单阶段不需要“先提候选框再精修”这两步,直接在特征图上做密集预测。
多阶段检测器(Faster R-CNN、Mask R-CNN、Cascade R-CNN)则多出rpn_head和roi_head:RPN 先快速筛出一批可能包含物体的候选框,RoI Head 再对这些候选框对应的特征区域做精细分类和回归。这个“粗筛 + 精修”的思路,是双阶段方法在精度上长期优于单阶段方法的关键原因之一。
看到这里你就明白了:mmDetection 的model配置不是一个“模型的形状描述”,而是一份组件清单。每个组件都是一个dict,里面写着“我要用什么模块、参数是多少”。搞清楚每个组件的参数含义,是理解这份清单的前提。
2. 主干网络 Backbone:特征的底层来源
2.1 ResNet 配置字段逐项解读
几乎九成以上的 mmDetection 模型都基于 ResNet。打开一份 Faster R-CNN 配置,backbone常常长这样:
backbone=dict( type='ResNet', depth=50, num_stages=4, out_indices=(0, 1, 2, 3), frozen_stages=1, norm_cfg=dict(type='BN', requires_grad=True), norm_eval=True, style='pytorch'),这些字段看着简单,但每一个都可能影响你的实验成败。
depth很好理解,50 代表 ResNet-50。很多人会随手改成 101,然后发现训练速度慢了一大截、显存爆掉,这很正常,毕竟更深的主干意味着更大的计算量。改depth之前先掂量一下自己的 GPU。
num_stages是 ResNet 的 stage 数量。ResNet 默认 4 个 stage,每个 stage 对应一个下采样倍率:stage1 输出 1/4 分辨率特征,stage2 是 1/8,stage3 是 1/16,stage4 是 1/32。这也是out_indices的可选范围为什么是 0 到 3。
out_indices决定 backbone 向外输出哪些 stage 的特征。(0, 1, 2, 3)表示把四个 stage 的特征全部输出。如果你是做语义分割或者轻量检测,可能只需要(2, 3)甚至(3,),这样可以省掉前面阶段的输出计算,但对检测来说,多尺度特征很重要,一般会全部输出,或者至少输出后三个。
frozen_stages是很多人容易忽略的字段。它表示冻结前几个 stage 的参数不参与训练。默认值是 1,也就是 stage1 之前的 stem 和 stage1 整个被冻住。这么做的原因是:主干网络通常使用在 ImageNet 上预训练的权重,浅层特征(边缘、纹理)比较通用,不需要在目标任务上重新学,冻结它们可以省显存、加快收敛。如果你在任务上与 ImageNet 差异很大,或者数据量很少,也可以把frozen_stages设为 0 让全部参数都参与训练,但这会显著增加显存占用和训练时间,需要自己权衡。
norm_cfg和norm_eval是一对需要一起理解的概念。norm_cfg=dict(type='BN', requires_grad=True)表示使用 BatchNorm,且 BN 层的weight和bias是允许梯度更新的。但注意,norm_eval=True会强制所有 BN 层在训练时也切换到 eval 模式,意思是 BN 的均值/方差不更新,只更新缩放和平移参数。这个组合在迁移学习里非常常见:预训练 BN 的统计量已经足够好,微调时不更新它们可以有效避免小 batch size 带来的统计量抖动。如果你自己改网络时,把 BN 换成了 GroupNorm 之类的,记得相应调整这套配置。
style='pytorch'和style='caffe'的区别在于 stem 结构。PyTorch 官方实现里 ResNet 的 stem 是一个 7x7 卷积加一个 3x3 的 max pooling;Caffe 版本则没有 pooling,或者在第一层卷积上直接 stride=2。这个字段影响了 backbone 实际搭出来的结构,也决定了加载预训练权重的方式,通常保持默认的pytorch不需要管。
2.2 更换主干网络时的三个注意点
我在实际项目里换过不少 backbone,踩过的坑基本集中在三个地方。
第一,注意out_indices与后面 neck 的in_channels要对齐。比如 ResNet-50 四个 stage 的输出通道数是[256, 512, 1024, 2048],如果你换了一个输出通道数不同的主干,比如某些轻量网络输出[24, 56, 120, 248],那 FPN 的in_channels必须同步修改,否则 1x1 卷积的输入维度直接 mismatch,跑起来就是 TypeError。
第二,预训练权重的键名匹配问题。mmDetection 加载预训练权重时是拿 state dict 的 key 做严格匹配的,如果你换了一个结构稍微不同的 backbone,比如 ResNet 的 stem 换了,那么预训练权重里对应层的 key 就对不上。这时候你需要在训练脚本里加--cfg-options model.backbone.pretrained=...,或者用load_from配合strict=False来忽略不匹配的层。有一次我改了 stem 的结构,忘了处理权重加载,结果训练了 20 个 epoch 后 mAP 一直在 0 附近,排查了半天才意识到是预训练权重根本没加载上。这个问题很隐蔽,因为训练不会报错,只是精度起不来。
第三,frozen_stages和norm_eval需要根据你的 backbone 类型调整。如果你换了一个没有 BN 层的 backbone,或者用的 BN 在微调时会产生很大问题,保持默认配置反而会拖慢收敛。适合初学者的建议是:先保持默认配置跑通,确认架构没问题,再去调这些“精细化”参数。
3. 特征金字塔 Neck:让不同尺度的目标都被看见
3.1 FPN 的配置与机制
FPN(Feature Pyramid Network)是当前检测框架里最常见、也最重要的 neck。它在配置里长这样:
neck=dict( type='FPN', in_channels=[256, 512, 1024, 2048], out_channels=256, num_outs=5),这份配置的意思是:FPN 接收来自 backbone 的四个不同尺度的特征(通道数分别是 256、512、1024、2048),经过处理后统一输出 256 通道的特征图,最终向外输出 5 个尺度的特征。
FPN 的核心操作分两条路径。自底向上的路径就是 backbone 本身——分辨率越来越低但语义越来越丰富。FPN 额外加了一条自顶向下的路径:从分辨率最低但语义最强的 stage4 特征开始,通过 1x1 卷积统一通道数,然后逐级上采样,与自底向上的特征做逐元素相加。
这样做的效果是:高层特征获得了高分辨率的细节信息,低层特征获得了丰富的语义信息,每一层都同时拥有“是什么”和“在哪里”两种能力。目标检测中尺度变化很常见——一张图里大象可能占半个画面,远处的猫只有 20 个像素。没有 FPN 之前,老方法要么只在单一特征图上检测,对不同尺度很不友好,要么在多个尺度上分别跑一遍网络,计算量爆炸。FPN 用很小的额外计算量就拿到了多尺度的能力,这也是它成为标配的原因。
3.2 配置中的几个隐蔽细节
num_outs表示 FPN 输出的特征金字塔层数。它通常比in_channels的列表长度多 1,比如这里in_channels有 4 个,num_outs=5,意味着除了 backbone 的 4 层特征,FPN 还会在最高层(1/32 分辨率)之上再做一次 stride=2 的下采样,额外产生一个 1/64 分辨率的特征层(通常叫 P6)。P6 对检测超大目标有帮助,但如果你处理的任务里目标普遍偏小,反而没必要保留这一层——小目标天然在高分辨率特征层上更容易被检测到。我曾经在一个小目标数据集上测试过,把num_outs从 5 改成 4,精度反而略有提升,因为 P6 层贡献的全是误检。
out_channels是 FPN 所有输出层的统一通道数。它在后续 head 的in_channels里必须保持一致。很多人改网络时只调了 backbone 的深度,忘了 FPN 的out_channels也要匹配 head 的输入通道,结果又是一堆维度报错。
如果你用的是PAFPN(Path Aggregation FPN),它会在 FPN 的基础上再加一条自底向上的增强路径,让底层细节信息也能向上传递,对小目标更友好。配置上的差别是把type='FPN'换成type='PAFPN',同时多了num_outs之外的一些下采样配置。YOLO 系列检测器里常用的就是这种结构。
经验之谈:如果项目里目标尺寸跨度很大,优先考虑保留完整的 5 层 FPN;如果目标普遍偏小,可以试试去掉最深层,或者在neck后面接一个尺寸较大的特征层做检测。这类小改动往往比更换 backbone 收益来得更快。
4. 检测头 Head:分类和回归真正发生的地方
4.1 单阶段 dense head 的配置逻辑
单阶段检测器的 head 直接在 neck 输出的特征图上做密集预测。以 RetinaNet 为例,bbox_head大概是:
bbox_head=dict( type='RetinaHead', num_classes=80, in_channels=256, stacked_convs=4, feat_channels=256, anchor_generator=dict( type='AnchorGenerator', octave_base_scale=4, scales_per_octave=3, ratios=[0.5, 1.0, 2.0], strides=[8, 8, 16, 32, 64]), bbox_coder=dict( type='DeltaXYWHBBoxCoder', target_means=[0.0, 0.0, 0.0, 0.0], target_stds=[1.0, 1.0, 1.0, 1.0]), loss_cls=dict( type='FocalLoss', use_sigmoid=True, loss_weight=1.0), loss_bbox=dict(type='L1Loss', loss_weight=1.0)),核心概念有两个:anchor 的生成和bbox 的编解码。
anchor_generator里的strides指的是特征图下采样倍数,也就是该层特征图上每个像素对应原图上的步长。特征图的每个像素都会铺上若干 anchor。octave_base_scale=4表示基础尺寸为 4 像素,scales_per_octave=3表示在每个“倍频程”内取 3 种尺度,ratios=[0.5, 1.0, 2.0]表示 3 种长宽比。所以每个像素上,RetinaNet 默认生成3 (scales) x 3 (ratios) = 9个 anchor,这些 anchor 覆盖了不同尺度、不同形状的目标候选。
bbox_coder负责的是“框的表示方式”。检测器一般不会直接回归框的四个坐标绝对值,而是回归“相对 anchor 的偏移量”,因为直接回归绝对值太难学。DeltaXYWHBBoxCoder把真实框转换成相对于 anchor 的(dx, dy, dw, dh),训练时让网络去预测这四个值。target_stds是归一化用的标准差,RPN 里通常是[1.0, 1.0, 1.0, 1.0],RCNN 精修头里通常是[0.1, 0.1, 0.2, 0.2],因为精修阶段框的偏移量更小,用更小的标准差可以让回归目标放大一些,训练更好收敛。
这里要提醒一个非常容易踩的坑:num_classes一定不要随便写。它应该等于“目标类别数“,不含背景类别。比如你的数据集有 10 类物体,num_classes就是 10。如果你从别人那里复制了一份 COCO 的配置,里面是 80,忘了改,训练时 loss 计算、精度评估都会错得离谱,而且往往表现出一种“loss 降了但 mAP 极低”的诡异状态。
4.2 双阶段 RPN Head + RoI Head 的配合
双阶段检测器把检测头分成两个阶段,这是它在精度上更“讲究”的地方。
RPN(Region Proposal Network)的作用是快速生成候选框。它在每个特征层上做一次二分类(有没有物体)和一次粗回归,挑出几千个候选框,再通过 NMS 筛到几百个。RPN 本身并不知道框里是什么类别,它只负责“这里可能有东西”。这个设计来自人类视觉的一个直觉:先快速扫一遍场景锁定可疑区域,再仔细看清每个区域里到底是什么。
RoI Head 则是真正的“精修专家”。它对 RPN 给出的每个候选框,用RoI Align从特征图上抠出对应的区域特征,然后统一缩放到固定尺寸(比如 7x7),送到全连接层里做分类和精细回归。
RoI Align 是双阶段方法里特别重要的组件。在它之前,RoI Pooling 用的是两次量化取整,会引入像素级的偏差,对小目标尤其不友好。RoI Align 用双线性插值避免了量化误差,这也是 Mask R-CNN 之后几乎所有双阶段方法都默认使用 RoI Align 的原因。配置里对应的字段是:
roi_head=dict( type='StandardRoIHead', bbox_roi_extractor=dict( type='SingleRoIExtractor', roi_layer=dict(type='RoIAlign', output_size=7, sampling_ratio=0), out_channels=256, featmap_strides=[4, 8, 16, 32]), bbox_head=dict( type='Shared2FCBBoxHead', in_channels=256, fc_out_channels=1024, roi_feat_size=7, num_classes=80, ... ), )这里有个细节:roi_layer里的output_size=7和bbox_head里的roi_feat_size=7必须一致。output_size决定 RoI Align 输出特征图的高宽,roi_feat_size决定后面全连接层的输入尺寸。如果你把output_size改成了 14,roi_feat_size还留着 7,那全连接的输入维度就会对不上。这种错误在改网络时特别容易出现,因为报错信息往往发生在网络前向的最后阶段,定位起来比较费时间。
双阶段方法还有一个隐含的好处:RPN 已经把绝大多数背景框筛掉了,RoI Head 处理的候选框数量远少于单阶段密集检测的 anchor 数量,所以分类器学起来更容易,精度自然更高。代价就是推理速度慢不少,毕竟多跑了一轮网络。到这一步,你应该也理解了为什么单阶段与双阶段的配置结构有那么大差异——本质上就是“一次到位”和“由粗到精”两种思路的区别。
5. 一个最小但完整的检测器配置逐行拆解
5.1 用 Faster R-CNN 走一遍完整配置
我不会让你去对着论文看结构,直接拆一份实际的 Faster R-CNN 配置,把它当成一张“JSON 地图”逐段翻译。完整的model配置长这样:
model = dict( type='FasterRCNN', backbone=dict( type='ResNet', depth=50, num_stages=4, out_indices=(0, 1, 2, 3), frozen_stages=1, norm_cfg=dict(type='BN', requires_grad=True), norm_eval=True, style='pytorch'), neck=dict( type='FPN', in_channels=[256, 512, 1024, 2048], out_channels=256, num_outs=5), rpn_head=dict( type='RPNHead', in_channels=256, feat_channels=256, anchor_generator=dict( type='AnchorGenerator', scales=[8], ratios=[0.5, 1.0, 2.0], strides=[4, 8, 16, 32, 64]), bbox_coder=dict( type='DeltaXYWHBBoxCoder', target_means=[0.0, 0.0, 0.0, 0.0], target_stds=[1.0, 1.0, 1.0, 1.0]), loss_cls=dict( type='CrossEntropyLoss', use_sigmoid=True, loss_weight=1.0), loss_bbox=dict(type='L1Loss', loss_weight=1.0)), roi_head=dict( type='StandardRoIHead', bbox_roi_extractor=dict( type='SingleRoIExtractor', roi_layer=dict(type='RoIAlign', output_size=7, sampling_ratio=0), out_channels=256, featmap_strides=[4, 8, 16, 32]), bbox_head=dict( type='Shared2FCBBoxHead', in_channels=256, fc_out_channels=1024, roi_feat_size=7, num_classes=80, bbox_coder=dict( type='DeltaXYWHBBoxCoder', target_means=[0.0, 0.0, 0.0, 0.0], target_stds=[0.1, 0.1, 0.2, 0.2]), reg_class_agnostic=False, loss_cls=dict( type='CrossEntropyLoss', use_sigmoid=False, loss_weight=1.0), loss_bbox=dict(type='L1Loss', loss_weight=1.0))) )我们来一步一步地看。type='FasterRCNN'是检测器外壳的类型,它决定了训练流程怎么编排、前向推理走哪条路。backbone是 ResNet-50,输出四层特征;neck是 FPN,把四层特征统一成 256 通道的 5 层金字塔;rpn_head在每层特征上生成scales=[8]和 3 种宽高比组合出的 3 个 anchor(实际 RPN 一般是 3 个,因为scales只有 1 个值、ratios有 3 个);roi_head用 RoI Align 提取候选框特征,然后通过两层全连接做分类和边界框精回归。
一个需要注意的对应关系:rpn_head的anchor_generator.strides=[4, 8, 16, 32, 64]有 5 个值,和neck的num_outs=5一一对应。如果 FPN 输出层的数量或者这些层对应的 stride 变了,这里必须同步修改。这个对应关系在改num_outs时很容易漏掉,出错的表现是 RPN 在某个特征层上找不到匹配的 anchor 配置,直接报错。
bbox_head里的reg_class_agnostic=False是一个值得理解的字段。False意味着回归分支为每个类别各预测一组边界框偏移量,框修正得更精细,但参数量也更大,最后的全连接层输出维度从 4 变成4 * num_classes。True则表示所有类别共用一组回归参数,参数量小、速度更快,但精度有轻微损失。实际小数据集项目里,我一般会保持默认False,这也是官方配置的选择。
5.2 从配置到模型的实例化流程
配置里的dict最终是怎么变成可执行的网络模块的?mmDetection 的答案是:读取type字段,去对应的注册表里找到这个类的定义,把剩下的参数作为关键字参数传进去,完成实例化。这条链路的起点是:
from mmdet.models import build_detector model = build_detector(cfg.model, test_cfg=cfg.get('test_cfg'))build_detector展开之后,实际就是调用了build_from_cfg,先根据model.type拿到FasterRCNN这个类,然后创建backbone、neck、rpn_head、roi_head这些子模块,再全部作为参数塞进FasterRCNN的构造函数。子模块的构建方式也是一样的——读各自的type,找注册表,实例化。
这套机制的好处是:你不需要写一堆if backbone == 'resnet50': return ResNet()之类的分支代码。所有模块都把自己注册到一个全局字典里,配置里写什么type,框架就找什么类。这也是为什么你需要看mmdet/models/backbones/resnet.py源码时,在文件末尾总会看到一句:
@BACKBONES.register_module() class ResNet(nn.Module): ...如果你调试网络结构时想知道某个中间结果的 shape,最简单的办法是在对应模块的forward里打印x.shape。因为模块都是直接通过配置构建的,打断点也好、加打印也好,都和你自己写的一个普通 PyTorch 模型没有区别,这也是 mmDetection 对新手比较友好的地方。
6. 网络的可定制性:注册器机制与自定义组件
6.1 注册器机制到底做了什么
这一节可能是 mmDetection 全网架构里最“劝退”也最有价值的部分。注册器(Registry)本质就是一个“类名到类的映射表”,但它的巧妙之处在于把“注册”这件事做成了装饰器。
当你新建一个自定义 backbone,代码大概是这样的:
from mmdet.models.builder import BACKBONES @BACKBONES.register_module() class MyBackbone(nn.Module): def __init__(self, arg1, arg2): super().__init__() self.arg1 = arg1 self.arg2 = arg2 ... def forward(self, x): ... return outs只要这个文件被 import 过,MyBackbone就会被自动写进BACKBONES这个注册表。之后你在配置里写:
backbone=dict( type='MyBackbone', arg1=..., arg2=...)框架构建时就会执行BACKBONES.get('MyBackbone')找到这个类,然后用arg1和arg2作为参数实例化。
我强烈建议你搞清楚mmdet/models/builder.py里那几十行代码,里面定义了BACKBONES、NECKS、ROI_EXTRACTORS、HEADS、DETECTORS等注册表。搞清楚这些注册表的名字,你就知道自己的模块应该注册到哪里:
- 新主干网络 →
@BACKBONES.register_module() - 新特征融合模块 →
@NECKS.register_module() - 新检测头 →
@HEADS.register_module() - 新检测器外壳 →
@DETECTORS.register_module()
这个机制是 mmDetection 支持复用的核心。也正是因为注册器的存在,你会发现很多模块的内部实现可能不一样,但只要它们注册到同一个表里、接口一致,就能无缝替换。这就是“配置驱动”的真正含义。
6.2 自定义组件时最常见的三个坑
我见过太多人卡在自定义这一步,基本都是三类问题。
第一类,配置里写了type='MyModule',运行时报KeyError: 'MyModule' is not in the ... registry。原因几乎总是:你虽然写好了类和装饰器,但这个文件没有被导入。mmDetection 默认只会导入mmdet/models/__init__.py里的东西,你自己新建的.py文件如果不被任何__init__.pyimport,注册表里就没有它。解决办法是在对应的__init__.py里加一行from .my_module import MyBackbone。这行代码不直接做事,但它触发文件执行,装饰器才算生效。
第二类,build_from_cfg报类型不匹配。这通常是因为你的模块__init__里声明的参数名和配置 dict 里的 key 不一致,或者__init__的参数没有给默认值,导致cfg里的参数匹配不上。调试方法很简单,在模块的__init__里打一个断点,看实际收到的 kwargs 是什么。
第三类,改了网络结构后,加载预训练权重报错。这与注册器没关系,但最容易让人心态爆炸。报错信息一般是size mismatch for ...。原因很简单,预训练权重的层和你的新网络层不完全对应。规范做法是在配置的load_from里指定预训练权重,然后训练脚本里用--cfg-options把model.train_cfg相关的严格加载关掉,或者在代码里调用load_state_dict时设置strict=False。不过要注意,strict=False是一个有风险的操作,它会静默忽略缺失的层,有时候反而掩盖了真正的维度错误。我的建议是:加载报错后,先明确搞清楚哪些层不匹配,确认是预期的结构差异再关掉严格模式。
7. 自主修改网络架构的实操经验
7.1 一次“换主干加检测头”的完整过程
假设你想把默认的 ResNet-50 backbone 换成一个新的轻量网络,再在 FPN 后面加一个小模块,具体可以怎么做?我会分步骤介绍,这套流程我自己反复用过很多次,是通用的。
第一步,新建一个文件mmdet/models/backbones/my_backbone.py(或者放在你自己的插件目录里),实现网络结构。不需要从头写卷积层,复用torchvision或timm里的现成模型也行,只要保证forward返回一个 tuple 或者 list,里面是不同 stage 的特征图。然后在文件末尾加上@BACKBONES.register_module()。
第二步,注册。在mmdet/models/backbones/__init__.py里导入这个文件。注意,__init__.py的顺序不影响执行结果,只要有 import 语句就够了。
第三步,改配置。把backbone里的type改成你的类名,并根据你的网络实际输出通道数调整out_indices和后面neck.in_channels。一个常见做法是先在骨干网络内部做一次print(outs.shape),跑一次单 batch 的前向,确认输出维度和预期一致,再改配置。这一步能帮你省大量排查时间。
第四步,改neck和head的通道数,使其与 backbone 输出对齐。如果你只是换了depth而没有换结构,这一步甚至可以不动;如果你换了完全不同的网络,那么in_channels必须改。同时检查一下rpn_head的strides和roi_head里featmap_strides是否和你的 FPN 输出层级 match。
第五步,处理预训练权重。如果新 backbone 有预训练权重,用load_from指定;如果结构差异很大,用转换脚本先对齐状态字典里的 key,实在对齐不了的才考虑strict=False。
整个过程听起来繁琐,但这就是科研里最常见的“改配置迭代”流程。跑通一次之后,你会觉得灵活度很高,所有组件的替换逻辑其实都是一样的。
7.2 常见问题排查速查表
我把实际项目里高频出现的报错和解决办法整理如下:
| 现象 | 原因 | 解决办法 |
|---|---|---|
KeyError: xxx is not in the ... registry | 模块没有注册 | 确认@xxx.register_module()存在,且文件被 import |
前向时报size mismatch | 某个模块的输入输出通道数不一致 | 检查 backbone 输出通道、neckin_channels、headin_channels |
RoI Align相关 shape 错误 | roi_layer.output_size和roi_feat_size不一致 | 保持两个值一致,并检查 head 里 fc 层的输入维度 |
加载权重时报size mismatch | 新网络和预训练权重结构不同 | 调整 key 对齐,必要时设置strict=False |
| 训练 loss 为 NaN | 学习率太大,或正则化设置不当 | 降低学习率,检查norm_cfg设置,排查数据是否存在异常值 |
改了num_classes但评估结果异常 | 类别数不匹配 | 确认num_classes等于数据集的类别数量(不含背景类) |
| 显存溢出 | batch size 过大,或 backbone 过深 | 减小 batch size,降低输入分辨率,考虑使用更浅的 backbone |
还有一个容易被忽略的问题:很多人会直接修改官方配置里的model字段,但配置继承时没有处理好。如果一份配置用了_base_继承另一个配置,你在子配置里写了新的model,那么整体 model dict 会替换掉 base 里的 model,而不是做深层的逐 key 合并。如果你只是想覆盖某一项,比如只改num_classes,正确的写法是写一个modeldict,里面只放需要覆盖的字段吗?不对——mmDetection 的配置继承只做顶层 dict 的合并,所以你要改model.roi_head.bbox_head.num_classes,最安全的做法是把整条链路都写全:model=dict(roi_head=dict(bbox_head=dict(num_classes=10)))。从顶层开始每一级都给出 dict,避免合并时悄悄丢了其他字段。我在早期因为这个机制吃过亏:只写了model=dict(roi_head=dict(bbox_head=dict(num_classes=2))),结果整个roi_head被替换成了只有bbox_head的 dict,跑起来一堆配置缺失。搞清楚配置继承规则,能帮你少折腾很多个晚上。
最后聊几句
我在实际使用这个框架的过程中体会到,mmDetection 的网络架构设计最值得学习的反而不是某个检测算法本身,而是它这套模块拆分 + 注册机制 + 配置驱动的组装理念。刚开始学的时候,别急着追求理解每个算法的数学细节,先学会“看配置猜结构”:拿到一份配置,在脑子里过一遍 backbone 的输出是什么形状、neck 怎么融合、head 在哪一层做分类和回归,基本上网络就活了。等你改过几次 backbone、加过几次自定义模块之后,再回头看那些论文里的检测器,会发现它们之间的距离其实没有想象中那么大。
最后再分享一个小技巧:如果你想快速验证某个 backbone 改动到底效果如何,不需要把整个训练流程跑完。先拿一个很小的数据集、只训练几个 epoch,观察 loss 的下降趋势和大致精度,心里有数了再上全量数据。这个阶段跑得快,迭代才会快。祝你的实验早日跑通,指标早日漂亮起来。