017、mAP 评价指标手动计算Precision-Recall 曲线积分与 mAP 0.5:0.95 的实现从一次模型评估翻车说起去年有个项目我训练了一个YOLOv5s检测行人验证集mAP 0.5显示0.89心里美滋滋。结果部署到实际场景漏检率高达30%。排查了半天发现是mAP计算时我直接调用了torchmetrics的接口但那个版本默认的IoU阈值是0.5而我的目标是小行人IoU 0.5太宽松了很多半身遮挡的框都被算成正样本。后来我手动实现了mAP 0.5:0.95的计算才发现0.5和0.95的mAP差距能到0.3以上。从那以后我再也不敢把mAP当黑盒用了。Precision-Recall曲线别被平滑的曲线骗了很多人以为PR曲线是连续的、光滑的像教科书画的那样。实际计算时PR曲线是阶梯状的因为预测框的置信度是离散的。我们按置信度降序排列所有预测框然后逐个判断是否匹配到GT。假设一张图里有3个GT框模型输出了5个预测框置信度分别是0.95、0.85、0.75、0.65、0.55。我们设定IoU阈值0.5逐个判断置信度0.95的框和GT1的IoU0.6匹配成功TP1FP0此时Precision1/11.0Recall1/3≈0.333置信度0.85的框和GT2的IoU0.55匹配成功TP2FP0Precision1.0Recall2/3≈0.667置信度0.75的框和GT3的IoU0.3匹配失败TP2FP1Precision2/3≈0.667Recall0.667置信度0.65的框和GT1的IoU0.7但GT1已经被匹配过了这个框算重复检测FP2Precision2/40.5Recall0.667置信度0.55的框和GT2的IoU0.4匹配失败FP3Precision2/50.4Recall0.667这里有个坑重复检测的处理。YOLO的NMS已经去重了但如果你自己写评估代码一定要记得每个GT只能匹配一次。我见过有人把重复检测也算TP结果mAP虚高。积分计算AP11点插值 vs 全点插值得到PR曲线后AP就是曲线下的面积。但PR曲线是阶梯的积分就是求和。有两种主流方法11点插值法VOC 2007标准在Recall 0.0到1.0之间均匀取11个点0.0, 0.1, …, 1.0每个点取该Recall右侧的最大Precision。比如Recall0.667时右侧最大Precision是0.667因为后面Precision更低所以Recall0.7对应的Precision也是0.667。然后对这11个点求平均。全点插值法VOC 2010后标准也是COCO标准直接对PR曲线所有转折点积分。每个Recall区间取该区间内最大的Precision。比如上面例子Recall从0.333到0.667Precision保持1.0这段面积是(0.667-0.333)*1.00.334。Recall从0.667到1.0Precision是0.667面积是(1.0-0.667)*0.667≈0.222。总AP0.3340.2220.556。实际代码里我习惯用全点插值因为更精确。但要注意计算前要把Precision序列做“右侧最大”处理也就是从右向左遍历把每个点的Precision更新为它右侧所有点中最大的Precision。这一步很多人漏掉导致AP偏低。mAP 0.5:0.9510个IoU阈值的平均COCO的mAP 0.5:0.95就是在IoU阈值从0.5到0.95步长0.05共10个阈值下分别计算AP然后取平均。每个IoU阈值下匹配规则不同IoU越高匹配越严格。比如IoU0.5时预测框和GT的IoU大于0.5就算匹配IoU0.95时必须大于0.95才算匹配。所以高IoU下很多框会被判为FPAP会显著下降。实现时我通常先对所有预测框按置信度排序然后对每个IoU阈值遍历一遍预测框计算TP/FP。但这样10个阈值就要遍历10次效率低。优化方法是对每个预测框预先计算它和所有GT的IoU矩阵然后对每个IoU阈值直接查表判断是否匹配。这样只需要一次IoU计算。代码实现逐行解析defcompute_mAP(pred_boxes,gt_boxes,iou_thresholds[0.5:0.05:0.95]):# pred_boxes: list of dict, 每个dict包含boxes, scores, labels# gt_boxes: list of dict, 每个dict包含boxes, labels# 1. 按置信度排序所有预测框all_preds[]forimg_id,predinenumerate(pred_boxes):forbox,score,labelinzip(pred[boxes],pred[scores],pred[labels]):all_preds.append({img_id:img_id,box:box,score:score,label:label})# 按置信度降序排序这里踩过坑一定要降序升序会导致PR曲线反着走all_preds.sort(keylambdax:x[score],reverseTrue)# 2. 对每个类别分别计算APclass_aps[]forclsinunique_labels:# 筛选当前类别的预测框和GTcls_preds[pforpinall_predsifp[label]cls]cls_gts[gforgingt_boxesifg[label]cls]# 统计每个图片的GT数量gt_counts{}forgincls_gts:gt_counts[g[img_id]]gt_counts.get(g[img_id],0)1total_gtssum(gt_counts.values())# 对每个IoU阈值计算APaps_per_iou[]foriou_thriniou_thresholds:# 初始化TP/FP数组长度等于预测框数量tp[0]*len(cls_preds)fp[0]*len(cls_preds)# 记录每个GT是否已被匹配gt_matched{g[img_id]:[False]*len(g[boxes])forgincls_gts}# 别这样写gt_matched {} 然后动态添加容易漏掉空GT的图片fori,predinenumerate(cls_preds):img_idpred[img_id]# 找到当前图片的GTimg_gts[gforgincls_gtsifg[img_id]img_id]ifnotimg_gts:fp[i]1continue# 计算IoUbest_iou0best_gt_idx-1forj,gtinenumerate(img_gts):ioucompute_iou(pred[box],gt[box])ifioubest_iou:best_iouiou best_gt_idxj# 判断是否匹配ifbest_iouiou_thrandnotgt_matched[img_id][best_gt_idx]:tp[i]1gt_matched[img_id][best_gt_idx]Trueelse:fp[i]1# 计算Precision和Recalltp_cumsumnp.cumsum(tp)fp_cumsumnp.cumsum(fp)precisionstp_cumsum/(tp_cumsumfp_cumsum1e-6)# 加小常数防止除零recallstp_cumsum/total_gts# 计算AP全点插值# 先做右侧最大处理foriinrange(len(precisions)-2,-1,-1):precisions[i]max(precisions[i],precisions[i1])# 积分ap0foriinrange(len(recalls)-1):ap(recalls[i1]-recalls[i])*precisions[i1]aps_per_iou.append(ap)# 当前类别的mAP是10个IoU阈值的平均class_aps.append(np.mean(aps_per_iou))# 最终mAP是所有类别的平均returnnp.mean(class_aps)这段代码有几个关键点排序降序必须按置信度从高到低否则PR曲线会乱。GT匹配记录每个GT只能匹配一次用布尔数组记录。右侧最大处理从右向左遍历把Precision更新为右侧最大值。这一步很多人用循环实现但numpy有更高效的方法np.maximum.accumulate(precisions[::-1])[::-1]。积分用相邻Recall的差值乘以右侧的Precision。注意是乘precisions[i1]因为右侧最大处理后每个点的Precision代表该Recall右侧的最大值。个人经验别踩这些坑IoU计算精度用float32就够了但如果你用float16累积误差会导致mAP波动0.01左右。我吃过这个亏后来统一用float64计算IoU。空类别处理如果某个类别在验证集中没有GT但模型输出了预测框这个类别的AP怎么算COCO官方是忽略这个类别不参与平均。但有些实现会算成0导致mAP偏低。建议统一如果GT数量为0跳过该类别。多尺度预测YOLO输出多个尺度的特征图每个尺度有不同大小的锚框。计算mAP时所有尺度的预测框要合并后统一排序不能分开算。置信度阈值mAP计算时通常不设置信度阈值所有预测框都参与。但实际部署时你会设一个阈值比如0.5来过滤低置信度框。所以mAP反映的是模型在所有置信度下的综合性能而实际部署性能取决于你选的阈值。mAP 0.5:0.95的物理意义它衡量的是模型在不同定位精度要求下的平均表现。如果你的应用对定位精度要求不高比如检测车辆框稍微偏一点没关系mAP 0.5更有参考价值。如果要求高精度定位比如医学图像中的病灶检测mAP 0.95才是关键。调试技巧当mAP异常低时先检查IoU计算是否正确。我写过一个小脚本随机生成几个预测框和GT手动计算mAP然后和代码输出对比。这样能快速定位问题。最后说一句mAP只是一个指标别迷信它。我见过mAP 0.8的模型在实际场景中不如mAP 0.7的模型因为mAP没有考虑推理速度、内存占用、小目标检测能力等。评估模型时一定要结合你的业务场景多看几个指标比如F1-score、推理时间、模型大小。
017、mAP 评价指标手动计算:Precision-Recall 曲线积分与 mAP 0.5:0.95 的实现
017、mAP 评价指标手动计算Precision-Recall 曲线积分与 mAP 0.5:0.95 的实现从一次模型评估翻车说起去年有个项目我训练了一个YOLOv5s检测行人验证集mAP 0.5显示0.89心里美滋滋。结果部署到实际场景漏检率高达30%。排查了半天发现是mAP计算时我直接调用了torchmetrics的接口但那个版本默认的IoU阈值是0.5而我的目标是小行人IoU 0.5太宽松了很多半身遮挡的框都被算成正样本。后来我手动实现了mAP 0.5:0.95的计算才发现0.5和0.95的mAP差距能到0.3以上。从那以后我再也不敢把mAP当黑盒用了。Precision-Recall曲线别被平滑的曲线骗了很多人以为PR曲线是连续的、光滑的像教科书画的那样。实际计算时PR曲线是阶梯状的因为预测框的置信度是离散的。我们按置信度降序排列所有预测框然后逐个判断是否匹配到GT。假设一张图里有3个GT框模型输出了5个预测框置信度分别是0.95、0.85、0.75、0.65、0.55。我们设定IoU阈值0.5逐个判断置信度0.95的框和GT1的IoU0.6匹配成功TP1FP0此时Precision1/11.0Recall1/3≈0.333置信度0.85的框和GT2的IoU0.55匹配成功TP2FP0Precision1.0Recall2/3≈0.667置信度0.75的框和GT3的IoU0.3匹配失败TP2FP1Precision2/3≈0.667Recall0.667置信度0.65的框和GT1的IoU0.7但GT1已经被匹配过了这个框算重复检测FP2Precision2/40.5Recall0.667置信度0.55的框和GT2的IoU0.4匹配失败FP3Precision2/50.4Recall0.667这里有个坑重复检测的处理。YOLO的NMS已经去重了但如果你自己写评估代码一定要记得每个GT只能匹配一次。我见过有人把重复检测也算TP结果mAP虚高。积分计算AP11点插值 vs 全点插值得到PR曲线后AP就是曲线下的面积。但PR曲线是阶梯的积分就是求和。有两种主流方法11点插值法VOC 2007标准在Recall 0.0到1.0之间均匀取11个点0.0, 0.1, …, 1.0每个点取该Recall右侧的最大Precision。比如Recall0.667时右侧最大Precision是0.667因为后面Precision更低所以Recall0.7对应的Precision也是0.667。然后对这11个点求平均。全点插值法VOC 2010后标准也是COCO标准直接对PR曲线所有转折点积分。每个Recall区间取该区间内最大的Precision。比如上面例子Recall从0.333到0.667Precision保持1.0这段面积是(0.667-0.333)*1.00.334。Recall从0.667到1.0Precision是0.667面积是(1.0-0.667)*0.667≈0.222。总AP0.3340.2220.556。实际代码里我习惯用全点插值因为更精确。但要注意计算前要把Precision序列做“右侧最大”处理也就是从右向左遍历把每个点的Precision更新为它右侧所有点中最大的Precision。这一步很多人漏掉导致AP偏低。mAP 0.5:0.9510个IoU阈值的平均COCO的mAP 0.5:0.95就是在IoU阈值从0.5到0.95步长0.05共10个阈值下分别计算AP然后取平均。每个IoU阈值下匹配规则不同IoU越高匹配越严格。比如IoU0.5时预测框和GT的IoU大于0.5就算匹配IoU0.95时必须大于0.95才算匹配。所以高IoU下很多框会被判为FPAP会显著下降。实现时我通常先对所有预测框按置信度排序然后对每个IoU阈值遍历一遍预测框计算TP/FP。但这样10个阈值就要遍历10次效率低。优化方法是对每个预测框预先计算它和所有GT的IoU矩阵然后对每个IoU阈值直接查表判断是否匹配。这样只需要一次IoU计算。代码实现逐行解析defcompute_mAP(pred_boxes,gt_boxes,iou_thresholds[0.5:0.05:0.95]):# pred_boxes: list of dict, 每个dict包含boxes, scores, labels# gt_boxes: list of dict, 每个dict包含boxes, labels# 1. 按置信度排序所有预测框all_preds[]forimg_id,predinenumerate(pred_boxes):forbox,score,labelinzip(pred[boxes],pred[scores],pred[labels]):all_preds.append({img_id:img_id,box:box,score:score,label:label})# 按置信度降序排序这里踩过坑一定要降序升序会导致PR曲线反着走all_preds.sort(keylambdax:x[score],reverseTrue)# 2. 对每个类别分别计算APclass_aps[]forclsinunique_labels:# 筛选当前类别的预测框和GTcls_preds[pforpinall_predsifp[label]cls]cls_gts[gforgingt_boxesifg[label]cls]# 统计每个图片的GT数量gt_counts{}forgincls_gts:gt_counts[g[img_id]]gt_counts.get(g[img_id],0)1total_gtssum(gt_counts.values())# 对每个IoU阈值计算APaps_per_iou[]foriou_thriniou_thresholds:# 初始化TP/FP数组长度等于预测框数量tp[0]*len(cls_preds)fp[0]*len(cls_preds)# 记录每个GT是否已被匹配gt_matched{g[img_id]:[False]*len(g[boxes])forgincls_gts}# 别这样写gt_matched {} 然后动态添加容易漏掉空GT的图片fori,predinenumerate(cls_preds):img_idpred[img_id]# 找到当前图片的GTimg_gts[gforgincls_gtsifg[img_id]img_id]ifnotimg_gts:fp[i]1continue# 计算IoUbest_iou0best_gt_idx-1forj,gtinenumerate(img_gts):ioucompute_iou(pred[box],gt[box])ifioubest_iou:best_iouiou best_gt_idxj# 判断是否匹配ifbest_iouiou_thrandnotgt_matched[img_id][best_gt_idx]:tp[i]1gt_matched[img_id][best_gt_idx]Trueelse:fp[i]1# 计算Precision和Recalltp_cumsumnp.cumsum(tp)fp_cumsumnp.cumsum(fp)precisionstp_cumsum/(tp_cumsumfp_cumsum1e-6)# 加小常数防止除零recallstp_cumsum/total_gts# 计算AP全点插值# 先做右侧最大处理foriinrange(len(precisions)-2,-1,-1):precisions[i]max(precisions[i],precisions[i1])# 积分ap0foriinrange(len(recalls)-1):ap(recalls[i1]-recalls[i])*precisions[i1]aps_per_iou.append(ap)# 当前类别的mAP是10个IoU阈值的平均class_aps.append(np.mean(aps_per_iou))# 最终mAP是所有类别的平均returnnp.mean(class_aps)这段代码有几个关键点排序降序必须按置信度从高到低否则PR曲线会乱。GT匹配记录每个GT只能匹配一次用布尔数组记录。右侧最大处理从右向左遍历把Precision更新为右侧最大值。这一步很多人用循环实现但numpy有更高效的方法np.maximum.accumulate(precisions[::-1])[::-1]。积分用相邻Recall的差值乘以右侧的Precision。注意是乘precisions[i1]因为右侧最大处理后每个点的Precision代表该Recall右侧的最大值。个人经验别踩这些坑IoU计算精度用float32就够了但如果你用float16累积误差会导致mAP波动0.01左右。我吃过这个亏后来统一用float64计算IoU。空类别处理如果某个类别在验证集中没有GT但模型输出了预测框这个类别的AP怎么算COCO官方是忽略这个类别不参与平均。但有些实现会算成0导致mAP偏低。建议统一如果GT数量为0跳过该类别。多尺度预测YOLO输出多个尺度的特征图每个尺度有不同大小的锚框。计算mAP时所有尺度的预测框要合并后统一排序不能分开算。置信度阈值mAP计算时通常不设置信度阈值所有预测框都参与。但实际部署时你会设一个阈值比如0.5来过滤低置信度框。所以mAP反映的是模型在所有置信度下的综合性能而实际部署性能取决于你选的阈值。mAP 0.5:0.95的物理意义它衡量的是模型在不同定位精度要求下的平均表现。如果你的应用对定位精度要求不高比如检测车辆框稍微偏一点没关系mAP 0.5更有参考价值。如果要求高精度定位比如医学图像中的病灶检测mAP 0.95才是关键。调试技巧当mAP异常低时先检查IoU计算是否正确。我写过一个小脚本随机生成几个预测框和GT手动计算mAP然后和代码输出对比。这样能快速定位问题。最后说一句mAP只是一个指标别迷信它。我见过mAP 0.8的模型在实际场景中不如mAP 0.7的模型因为mAP没有考虑推理速度、内存占用、小目标检测能力等。评估模型时一定要结合你的业务场景多看几个指标比如F1-score、推理时间、模型大小。