多类目标检测训到一半,最让人心里没底的不是 loss 不降,而是 total mAP 从 0.342 爬到 0.361 之后,你完全不知道这 19 个千分点里是哪几个类别贡献的,又是哪几个类别在拖后腿。我见过太多人拿着一个总 mAP 反复调学习率,结果训了三天才发现某个类别从头到尾 AP 都是 0,模型根本没学会它。这篇东西就讲透一件事:在 MMDetection 3.x 里,用标准 COCO 格式数据集训练多类目标检测时,怎么把每个类别的 mAP 一条一条扒出来,包括配置怎么改、结果从哪拿、脚本怎么写、口径怎么对齐,以及我自己踩过的几个坑。不管你是刚把官方 demo 跑通的新手,还是已经在调长尾类别分布的老手,这里面的实操路径都能直接拿去用。核心关键词就三个:MMDetection 3.x、COCO 格式数据集、每类 mAP。
1. 别只盯一个 total mAP:多类检测为什么必须看每类结果
1.1 总 mAP 掩盖了什么
COCO 的 mAP 是所有类别 AP 的算术平均,这个平均动作本身就是一次信息压缩。假设你的数据集有 20 个类,其中 3 个大类样本量大、特征明显,AP 能到 0.6,剩下 17 个小类 AP 只有 0.1,最后的 mAP 依然是 0.175,看起来"还在涨"。这种数字在训练曲线上是平滑上升的,但它掩盖的事实是:模型已经放弃了一大半类别,只是被头部类别的分数撑着。更糟的情况是某些类别的 AP 直接被算成 0 或者不参与统计,你在总数上看到的波动,可能只是那几个大类在抖动,跟小类一点关系都没有。
我自己的习惯是,从第一个 epoch 验证开始就打开每类指标,不是为了看绝对值,而是看分布。一个健康的训练过程,头部类和尾部类的 AP 应该都在往上走,哪怕尾部类慢一些;如果某个类的 AP 从第 3 个 epoch 开始就不动了,或者掉下去再也没回来,那基本可以判定这一类出了问题——可能是标注噪声,可能是样本太少,也可能是它跟另一个类在特征空间上完全重合。这些东西看总 mAP 是永远看不出来的。
还有一种更隐蔽的情况:类别索引和标注文件里的 category_id 对不上。COCO 的 category_id 不要求连续,你在自定义数据集里把类别写成 1 到 20,模型训练时按 0 到 19 处理,评估时又按 json 里的 id 匹配,中间任何一环错位,最后表现出来的都是"某几个类 AP 特别低",但你会误以为是模型能力问题。打开每类指标,这类问题一眼就能看出来。
1.2 COCO 口径下每类 AP 到底怎么算出来的
要读懂每类 AP,得先知道它是怎么来的。COCO 评估的核心是pycocotools里的COCOeval,它对每个类别、每个 IoU 阈值、每个面积区间、每个最大检测数,都算出一条 precision-recall 曲线,然后对 precision 做插值积分得到 AP。最终的stats[0]是在 IoU 从 0.5 到 0.95、步长 0.05 共 10 个阈值上取平均,同时对所有类别取平均的结果。
单类的 AP 就是把"对所有类别取平均"这一步去掉。具体做法是在 precision 张量里固定类别维度,取所有面积区间(all)、所有最大检测数(通常是 100)下的 precision,再对 10 个 IoU 阈值求平均。所以你在 MMDetection 里看到的 per-class AP,默认口径是AP@[.5:.95],不是 AP50,也不是 AP75。这一点很多人第一次看会懵:明明某个类目测效果挺好,显示的 AP 才 0.4 出头,那是因为定位精度在 0.75 以上的 IoU 上被打折了。
另外要清楚,某一类如果 GT 数量为 0,或者你的预测里完全没有这一类,precision 张量对应的位置会是 -1,被过滤掉后 AP 就是 nan。这不是 bug,是 COCO 的既定行为。理解这一点,后面看到某个类显示-1或者nan时你就不会慌,也不会浪费时间去找不存在的代码问题。
2. 先摸清 MMDetection 3.x 的评估链路
2.1 val_evaluator 与 test_evaluator 的分工
3.x 相比 2.x 最大的结构变化是把 Runner、Loop、Hook 全部交给了 MMEngine,配置文件的写法也跟着变了。评估这块,2.x 时代你在data.val里塞一个evaluation = dict(metric='bbox'),3.x 直接把它拆成了两个独立字段:val_evaluator和test_evaluator。前者在训练过程中每个验证周期被ValLoop调用,后者在tools/test.py里被TestLoop调用。两者可以写成一模一样的内容,也可以是两套不同配置。
这个拆分看起来很琐碎,但实际用起来很方便。比如训练时你只想看总 mAP 省点日志,验证时用classwise=False;等训练完了单独跑测试,再开classwise=True详细看每类。完全不冲突。反过来也有坑:有人改了val_evaluator的ann_file,然后跑tools/test.py发现指标不对,其实是test_evaluator里那份没改。我排查过好几次这种问题,现在养成习惯,看到指标异常先 grep 一下配置文件里出现了几次ann_file。
在 3.x 的早期小版本里,test_evaluator这个字段还不存在,只能靠val_evaluator兜底,跑tools/test.py的时候会复用验证集的 evaluator。如果你手上的代码库版本比较老,配置文件里搜不到test_evaluator,那就直接改val_evaluator,效果一样。
2.2 CocoMetric 里跟"每类"相关的几个参数
CocoMetric是mmdet/evaluation/metrics/coco_metric.py里定义的类,继承自 MMEngine 的BaseMetric。跟每类指标直接相关的参数其实就一个classwise,但周边的几个参数会直接影响你拿到什么口径的结果,得一起说清楚。
| 参数 | 默认值 | 作用与注意事项 |
|---|---|---|
ann_file | 无(必填) | GT 标注路径,与验证集 dataloader 的ann_file必须指向同一份文件,写相对路径时会和配置里的data_root拼接 |
metric | 'bbox' | 可选'bbox'、'segm'、'proposal',也可以是列表,列表时每类 AP 会分别按两种任务输出 |
classwise | False | 打开后额外计算并输出每个类别的 AP,这是本文的核心开关 |
iou_thrs | np.linspace(0.5, 0.95, 10) | 改动它会同时改变总 mAP 和每类 AP 的口径,慎改 |
proposal_nums | [100, 300, 1000] | 影响 AR 系列指标,每类 AP 用的是第一个值(100) |
format_only | False | 置为True时只导出预测结果不算指标,通常配合outfile_prefix做离线评估 |
outfile_prefix | None | 导出文件的路径前缀,实际会生成.bbox.json或.segm.json |
prefix | 'coco' | 指标 key 的前缀,日志里看到的coco/bbox_mAP就是这个拼出来的 |
关于iou_thrs,我踩过一次印象很深的坑。当时为了快速看 AP50,随手在 evaluator 里写了iou_thrs=[0.5],结果每类 AP 确实变成 AP50 了,但日志里的coco/bbox_mAP也跟着变成了 AP50 的数值,和之前跑的实验完全没法对比。更麻烦的是实验记录里没标这一条,过了两周回头翻日志,怎么也对不上账。后来我的做法是:日常训练坚持默认口径不动,需要 AP50、AP75 的时候走独立脚本(第 4 节会给),两边互不干扰。
proposal_nums也值得提一句。COCO 默认对每张图最多保留 100 个检测参与 AP 计算,如果你的场景小目标极其密集,一张图里真实目标就超过 100 个,那 AP 会被这个上限压制。这个时候去改proposal_nums是有意义的,但它同样会影响全局指标口径,改之前记得在实验记录里写一笔。
2.3 一行配置把 classwise 打开
最直接的改法就是打开配置文件,找到 evaluator 那一段,加一个键值。假设你的配置文件里原本是这样:
val_evaluator = dict( type='CocoMetric', ann_file=data_root + 'annotations/instances_val.json', metric='bbox') test_evaluator = val_evaluator改成:
val_evaluator = dict( type='CocoMetric', ann_file=data_root + 'annotations/instances_val.json', metric='bbox', classwise=True, format_only=False) test_evaluator = val_evaluator注意test_evaluator = val_evaluator这种写法在 3.x 里是允许的,配置系统会做一次深拷贝,改一个不会污染另一个。但如果你写的是test_evaluator = dict(...)重新定义一遍,那两边的classwise就要分别确认,别只改了一处。
如果你不想动配置文件(比如这个 config 是上游维护的,每次同步都会被覆盖),tools/train.py和tools/test.py都支持--cfg-options临时覆盖:
python tools/train.py configs/_custom_/det_multi_cls.py \ --cfg-options val_evaluator.classwise=Truepython tools/test.py work_dirs/det_multi_cls/det_multi_cls.py \ work_dirs/det_multi_cls/epoch_24.pth \ --cfg-options test_evaluator.classwise=True--cfg-options的点号语法对单层 dict 是好用的,但如果你的val_evaluator是个列表(比如同时挂了CocoMetric和某个自定义 metric),就得用下标,val_evaluator.0.classwise=True这种写法在部分 MMEngine 版本上不一定能正确合并,遇到报错就直接去改配置文件,别在这上面耗时间。
3. 三种拿到每类 mAP 的做法,按省事程度排
3.1 做法一:训练/测试时直接开 classwise
这是成本最低的一条路,配置改完就不用管了。每个验证周期结束,终端和日志里会多打印一坨内容,形如一个列表,里面是每个类别的名字和对应的 AP 值。日志文件在work_dirs/<实验名>/<时间戳>/vis_data/<时间戳>.log和同目录下的.json里,都能翻到。
这个做法的好处是完全零成本,不需要额外跑推理,预测结果在评估时已经算好了,多算一个每类 AP 的开销可以忽略不计。缺点也很明显:第一,输出格式是给人看的,不是给程序读的,想批量对比多个实验就得一坨一坨复制粘贴;第二,训练过程中每个 epoch 都打印一遍 20 个类别,日志会迅速膨胀,翻起来很烦;第三,它只给你默认口径的 AP,想要 AP50 每类还得另外想办法。
我的实际用法是:训练阶段一直开着,但只作为"监控"用,看趋势、看有没有某个类突然归零。真正做实验分析、写报告、横向对比的时候,走做法三。
另外提醒一句,多卡分布式训练时,评估结果只在 rank 0 上打印和写日志,其他卡的进程不会重复输出。这是BaseMetric的设计,不需要额外配置。如果你发现日志里一次出现了好几份重复的评估结果,那多半是DistributedSampler或者collect_device配错了,检查一下有没有手动改过dist相关参数。
3.2 做法二:从 pkl 结果里把 per-category 薅出来
tools/test.py支持--out参数,把 evaluator 返回的完整结果 dump 成 pickle:
python tools/test.py work_dirs/det_multi_cls/det_multi_cls.py \ work_dirs/det_multi_cls/epoch_24.pth \ --out work_dirs/det_multi_cls/test_result.pkl这个 pkl 里是一个字典,包含data_list、meta,以及一堆指标 key。打开classwise=True之后,里面会有一个跟"每类"相关的 key,不同小版本的名字会有点飘,常见的是coco/bbox_per_category_AP或者直接叫per_category_AP,值通常是一个列表,列表元素是字典,包含类别名和对应的 AP。
因为 key 名不稳定,我从来不硬编码,都是先打出来看一眼:
import pickle with open('work_dirs/det_multi_cls/test_result.pkl', 'rb') as f: obj = pickle.load(f) for k, v in obj.items(): if isinstance(v, (int, float)): print(f'{k:40s} {v:.4f}') elif 'category' in k.lower() or 'class' in k.lower(): print(f'{k:40s} {type(v)} len={len(v)}')看到那个列表之后,解析逻辑就很直白了。但要注意列表元素的结构也可能不同,有的是{'category': 'xxx', 'AP@[.5:.95]': 0.42},有的是{'xxx': 0.42},写解析的时候加个兜底判断:
def to_ap_map(raw): """把 per-category 的原始结构统一成 {类名: AP}""" out = {} for item in raw: if not isinstance(item, dict): continue if 'category' in item: name = item['category'] ap = item.get('AP@[.5:.95]', item.get('AP', float('nan'))) out[name] = float(ap) elif len(item) == 1: name, ap = next(iter(item.items())) out[name] = float(ap) return out这条路的优势是不需要重新跑一遍推理,pkl 已经在手上了,随便怎么解析。劣势是它只给你 evaluator 当时算的口径,AP50、AP75、分面积区间的 AP 一个都拿不到。如果只要一个总览,够用了。
3.3 做法三:format_only 导出 json,自己跑 COCOeval
这是我最推荐的方式,灵活性最高。核心思路是让 MMDetection 只负责推理和导出,把预测结果写成标准 COCO 结果 json,剩下所有指标计算都交给pycocotools,想怎么算就怎么算。这样做有几个实打实的好处:口径完全可控,AP、AP50、AP75、APs/APm/APl 都能拿;可以反复用不同参数重算,不用重跑模型;导出出来的 json 可以拿去跟别的框架对比,也可以交给同事复核。
配置只需要改一个地方:
test_evaluator = dict( type='CocoMetric', ann_file=data_root + 'annotations/instances_val.json', metric='bbox', format_only=True, outfile_prefix='./work_dirs/per_class_eval/val')然后正常跑测试,不需要额外参数:
python tools/test.py work_dirs/det_multi_cls/det_multi_cls.py \ work_dirs/det_multi_cls/epoch_24.pth跑完会在./work_dirs/per_class_eval/下生成val.bbox.json,如果你同时配了metric=['bbox', 'segm'],还会有val.segm.json。这个 json 的格式就是标准的 COCO detection results:一个列表,每个元素包含image_id、category_id、bbox、score,分割任务再加一个segmentation字段。
有一点必须提醒:format_only=True之后,终端上不会打印任何指标,coco/bbox_mAP之类的 key 也不会出现。第一次用的人经常会以为出错了,其实是正常的——它只负责导出,不负责算分。跑完去看输出目录有没有 json 文件就行。
4. 完整实操:从导出预测到生成每类指标表
4.1 先把预测导出成 COCO 结果 json
上面已经给了配置,这里补充几个实操细节。第一,outfile_prefix用相对路径时,是相对于你执行命令的当前工作目录,不是相对于配置文件。我一般统一写成./work_dirs/<实验名>/per_class/val这种形式,跟实验目录绑在一起,方便追溯。
第二,验证集的ann_file和 evaluator 里的ann_file必须是同一份。如果验证 dataloader 用的是instances_val.json,evaluator 里却指向了instances_val_remap.json,导出的检测结果本身没问题(它只是坐标和类别 id),但后面算分的时候 GT 对不上,指标会莫名其妙地偏低。这类问题在我这儿出现过两次,每次都是改配置时复制粘贴留下的后遗症。
第三,如果你的数据集是自定义的,类别名不是 COCO 那 80 类,metainfo=dict(classes=CLASSES)里的顺序要和 json 里categories的顺序保持一致。注意是"顺序"还是"id 对应",这两件事不一样:COCO 评估靠category_id匹配,理论上顺序无所谓;但如果你在别的脚本里按索引去查类别名,顺序错了就会张冠李戴。稳妥起见,我一般让两者完全一致,省得后面出幺蛾子。
4.2 逐类评估脚本
拿到val.bbox.json之后,写个独立脚本跑 COCOeval。核心技巧是:全局评估跑一次拿总指标,然后对每个category_id单独设置params.catIds再跑一次,拿单类指标。
# tools/per_class_eval.py import argparse import csv import json import os import numpy as np from pycocotools.coco import COCO from pycocotools.cocoeval import COCOeval def eval_one_cat(coco_gt, coco_dt, cat_id, iou_type='bbox'): coco_eval = COCOeval(coco_gt, coco_dt, iou_type) coco_eval.params.catIds = [cat_id] coco_eval.evaluate() coco_eval.accumulate() # 屏蔽 summarize 的 stdout 输出,避免刷屏 import contextlib, io with contextlib.redirect_stdout(io.StringIO()): coco_eval.summarize() return coco_eval.stats def main(): parser = argparse.ArgumentParser() parser.add_argument('--ann', required=True, help='GT 的 coco json') parser.add_argument('--det', required=True, help='导出的预测 json') parser.add_argument('--iou-type', default='bbox', choices=['bbox', 'segm']) parser.add_argument('--csv', default=None, help='结果输出路径') args = parser.parse_args() coco_gt = COCO(args.ann) with open(args.det, 'r') as f: dets = json.load(f) assert len(dets) > 0, '预测结果为空,检查 outfile_prefix 和 evaluator 配置' coco_dt = coco_gt.loadRes(dets) # 全局指标先打一份,作为基准 full = COCOeval(coco_gt, coco_dt, args.iou_type) full.evaluate() full.accumulate() full.summarize() rows = [] for cat in coco_gt.loadCats(coco_gt.getCatIds()): cat_id = cat['id'] num_gt = len(coco_gt.getAnnIds(catIds=[cat_id])) s = eval_one_cat(coco_gt, coco_dt, cat_id, args.iou_type) rows.append({ 'cat_id': cat_id, 'name': cat['name'], 'num_gt': num_gt, 'AP': s[0], 'AP50': s[1], 'AP75': s[2], 'APs': s[3], 'APm': s[4], 'APl': s[5], 'AR100': s[8], }) rows.sort(key=lambda r: (np.isnan(r['AP']), r['AP'])) header = ['cat_id', 'name', 'num_gt', 'AP', 'AP50', 'AP75', 'APs', 'APm', 'APl', 'AR100'] print('\n' + '\t'.join(header)) for r in rows: print('\t'.join( f"{r[h]:.4f}" if isinstance(r[h], float) else str(r[h]) for h in header)) if args.csv: os.makedirs(os.path.dirname(args.csv) or '.', exist_ok=True) with open(args.csv, 'w', newline='') as f: writer = csv.DictWriter(f, fieldnames=header) writer.writeheader() writer.writerows(rows) print(f'\n已写出 {args.csv}') if __name__ == '__main__': main()几个关键点解释一下。coco_dt = coco_gt.loadRes(dets)这一步会做一次合法性校验,如果你的 json 里出现了 GT 中不存在的category_id,它会直接报 "Results do not correspond to current coco set",这是最有效的错位检查手段,比后面肉眼看指标靠谱得多。
coco_eval.params.catIds = [cat_id]必须在evaluate()之前设置,因为evaluate()内部会遍历这个列表。设置完之后再accumulate()和summarize(),stats里就只剩这一类的指标了。循环里每次都新建一个COCOeval对象,是为了避免上一次的eval字典残留污染,虽然稍微慢一点,但结果干净。
contextlib.redirect_stdout那段是为了压掉summarize()每次都要打印 12 行表格的噪音。20 个类别就是 240 行,眼睛都要瞎了。压掉之后,全局那一次summarize()保留输出,作为参照。
跑法:
python tools/per_class_eval.py \ --ann data/coco/annotations/instances_val.json \ --det work_dirs/per_class_eval/val.bbox.json \ --csv work_dirs/per_class_eval/per_class.csv输出大概长这样(数值是示意):
cat_id name num_gt AP AP50 AP75 APs APm APl AR100 3 small_part 1182 0.4120 0.6613 0.4402 0.1822 0.4011 0.4920 0.5531 1 main_obj 9640 0.3866 0.6210 0.4108 0.2011 0.3822 0.4510 0.5231 7 rare_obj 96 -1.0000 -1.0000 -1.0000 -1.0000 -1.0000 -1.0000 -1.0000看到最后那种全是-1的行,就说明这一类在你的预测结果里一个检测框都没有,或者 GT 样本数为 0。这时候别急着改模型,先看num_gt,再看导出的 json 里到底有没有这个category_id。
4.3 结果整理成 CSV 和 Markdown 表
CSV 已经有了,往实验记录里贴的时候我一般再转一版 Markdown,只保留最关键的几列,按 AP 升序排,一眼就能看出短板在哪:
def to_markdown(rows, top_n=None): rows = sorted(rows, key=lambda r: (np.isnan(r['AP']), r['AP'])) if top_n: rows = rows[:top_n] lines = ['| 类别 | GT 数 | AP | AP50 | AR100 |', '| --- | --- | --- | --- | --- |'] for r in rows: lines.append( f"| {r['name']} | {r['num_gt']} | {r['AP']:.4f} | " f"{r['AP50']:.4f} | {r['AR100']:.4f} |") return '\n'.join(lines)把每个实验的这版表存成独立文件,命名带上实验名和 epoch,比如exp_det_multi_cls_epoch24.md。做 A/B 对比的时候,两张表并排一看,哪个类涨了、哪个类掉了、掉多少,比看总 mAP 直观十倍。我现在的实验记录文件夹里,每份记录都附了这么一张表,回溯的时候非常省事。
5. 踩坑记录与排查速查表
5.1 类别顺序错位与 ann_file 路径重复
类别错位是每类指标里最容易骗人的问题。表现是:某个类 AP 特别低,但你看图上的检测结果其实还行。原因往往是训练时metainfo里的classes顺序和标注 json 里categories的顺序不一致,模型输出的label索引在导出时被映射成了错误的category_id,评估时自然对不上。
判断方法很简单:拿一张图的可视化结果,看上面对应的类别名对不对。或者更直接一点,检查导出的 json 里的category_id集合,和 GT json 里的categories集合是否完全一致。如果预测里出现了 GT 中根本不存在的 id,loadRes会直接报错,这是最省事的自检。
路径重复是另一个高频坑。3.x 的配置文件里,data_root会在 dataset 和 evaluator 里各拼一次。如果你在ann_file里已经写了完整的相对路径data/coco/annotations/instances_val.json,同时data_root = 'data/coco/',实际拼出来就是data/coco/data/coco/annotations/...,直接找不到文件。3.x 在启动时会做一次路径检查并给出相对友好的报错,但如果你把ann_file写成了一个看似存在、实际是别的数据集的路径(比如不小心指到了 train 的标注),那就不会报错,只会得到一堆莫名其妙的低分。
5.2 出现 -1、nan 或某类直接消失
| 现象 | 最常见原因 | 排查动作 |
|---|---|---|
某类 AP 全是-1 | 该类的num_gt为 0,或预测里没有这个category_id | 先数 GT 标注条数,再 grep 导出 json |
某类 AP 是nan | 该类有 GT 但 precision 全被过滤 | 检查score_thr、nms是否把该类框全压掉了 |
loadRes报结果不匹配 | 预测里有 GT 不存在的category_id | 对比两边的 categories 集合 |
| 每类 AP 都低但总 mAP 正常 | 多半是看错了 evaluator(val 改成了 test 没改) | grep 配置文件里所有ann_file |
| 导出 json 为空列表 | format_only没生效,或outfile_prefix目录不存在 | 看终端有没有写文件日志,手动建目录 |
score_thr这个点在 3.x 里特别值得说。很多配置文件会在test_cfg里设score_thr=0.05,CocoMetric导出的时候会按这个阈值过滤,低于阈值的框不会写进 json。对头部类没影响,但小类本来就出不了几个高分框,被一过滤就彻底消失了,表现出来就是全是-1。遇到这种情况,把score_thr降到0.001再导一次,大概率就出来了。
5.3 日志刷屏、多卡只打一次、口径被改乱
classwise=True打开之后,日志体积会明显变大。如果你坚持按 epoch 保存日志,几十个 epoch 下来几百兆是常事。我的处理办法是训练阶段只在val_evaluator里开classwise=False,训练完用tools/test.py单独跑一次test_evaluator拿详细指标。既省日志,又不影响分析。
多卡那边,CocoMetric的collect_device默认是cpu,所有进程的结果会 gather 到 rank 0 上计算,只有 rank 0 输出。如果你看到多个进程各打一份,检查一下是不是自己改过collect_device或者手动绕过了BaseMetric的收集逻辑。
口径被改乱这件事,前面提过iou_thrs,还有一个隐蔽的是proposal_nums。有人为了提速把它改成[100]只有一个值,结果AR@1000这类指标直接不输出,stats数组长度都变了,后面按索引取值就全错位。如果你写脚本读stats,一定要按索引名而不是位置记忆,或者直接打印coco_eval.params确认一下。
6. 数字出来之后怎么用
6.1 长尾类与小类的针对性处理
拿到每类 AP 之后,第一件事是按num_gt排序看看分布。通常你会发现一个很明显的规律:GT 数量前 20% 的类别贡献了大部分 mAP,尾部 30% 的类别 AP 长期在 0.1 以下。这时候盲调学习率是没用的,得针对性地动手。
我常用的几招,按性价比排:一是重采样,让尾部类别在每个 batch 里出现的概率提高,MMDetection 里可以通过自定义Sampler或者在数据集层面做重复采样,代价是头部类别的收敛会慢一点;二是增强,针对小类做 copy-paste,把稀有类别的实例贴到别的图上,这个对样本数在几十到几百量级的类别效果最明显;三是调整 loss 的类别权重,但这一招我不太推荐,因为它会同时影响所有类别的梯度尺度,调不好整体指标会掉。
还有一个容易被忽略的点:如果某个类的 AP 一直上不去,但AR100挺高,说明模型其实能找到它,只是定位不准,这时候该动的是回归分支或者 anchor 匹配策略;如果AP和AR100都很低,那才是真的学不会,得回头看数据。把AP和AR100两列并排看,能省掉很多瞎试的时间。
6.2 配合混淆矩阵与日志曲线定位问题
单看每类 AP 只能知道"哪个类差",不知道"为什么差"。这时候配合两个工具会高效很多。
第一个是混淆矩阵,MMDetection 3.x 里对应tools/analysis_tools/confusion_matrix.py:
python tools/analysis_tools/confusion_matrix.py \ configs/_custom_/det_multi_cls.py \ work_dirs/det_multi_cls/epoch_24.pth \ work_dirs/confusion/ \ --show它会输出一个矩阵,横轴是预测类别,纵轴是 GT 类别。如果某两个类在矩阵上互相串得很厉害,那基本可以确定是特征区分度不够,而不是样本量的问题。这种情况加数据增强或者换更强的 backbone 比调阈值有用。
第二个是日志曲线,用tools/analysis_tools/analyze_logs.py把每轮的指标画出来:
python tools/analysis_tools/analyze_logs.py plot_curve \ work_dirs/det_multi_cls/20240101_120000/vis_data/20240101_120000.json \ --keys coco/bbox_mAP coco/bbox_mAP_50 \ --legend mAP AP50 \ --out work_dirs/map_curve.png如果classwise=True的每类指标也写进了 json 日志(取决于版本,有些版本会写),那就可以把某几个重点类别的曲线一起画出来,看它们是不是同步上升。不同步的话,说明这几个类的学习节奏不一样,可能需要分阶段解冻或者调整采样策略。
我个人在实际操作中的体会是,每类指标最大的价值不是"看分数",而是"排除法"。当总 mAP 停滞时,先拉每类表,如果所有类都在缓慢上升,那说明只是还没训够,继续跑;如果只有一两个类在拖,那就针对性地查这两个类的数据和标注;如果所有类都突然同时掉,那八成是配置或者数据管道出了问题,跟模型本身没关系。这个判断顺序帮我省下的时间,比任何调参技巧都多。