欢迎光临
我们一直在努力

YOLO 完全指南(五):看懂评测结果

看懂评测结果——从指标到错误归因的完整实践

在这里插入图片描述

上一堂课我们完成了目标检测样本的训练,模型跑起来了,但"跑通"不等于"够用"。这一节课我们要回答一个核心问题:这个模型到底行不行,不行的话问题出在哪?

mAP指标理解这恰恰是真正工作的开始。下面我会带着你从指标含义出发,一路走到手动归因分析。

一、先把指标读明白:Precision、Recall、mAP

1.1 Precision:模型说"有"的时候,有多可信?

Precision(精确率)的定义是:在所有被模型预测为正例的样本中,真正是正例的比例。

公式:Precision = TP / (TP + FP)

举个检测场景的例子:模型在 100 张图里画了 200 个框,其中 160 个框真的框住了目标(TP),40 个框是虚报的(FP)。那么 Precision = 160 / 200 = 0.8。

Precision 高,意味着模型"不乱报"。但光看 Precision 有个陷阱:一个极度保守的模型,只在十拿九稳时才画框,Precision 可以很高,但它会漏掉大量目标。

1.2 Recall:真实存在的目标,模型找出了多少?

Recall(召回率)的定义是:在所有真实存在的正例中,被模型检测出来的比例。

公式:Recall = TP / (TP + FN)

继续上面的例子:假设测试集里实际有 200 个目标,模型框住了其中 160 个(TP),漏掉了 40 个(FN)。那么 Recall = 160 / 200 = 0.8。

Recall 高,意味着模型"不放过"。同样地,一个疯狂画框的模型 Recall 可以接近 1,但 Precision 会惨不忍睹。

Precision 和 Recall 天然是一对矛盾。提高检测阈值(conf threshold),模型变谨慎,Precision 上升、Recall 下降;降低阈值,模型变激进,Recall 上升、Precision 下降。Ultralytics 的验证工具默认使用 0.001 的低置信度阈值来绘制 PR 曲线,正是为了看到模型性能的全貌。

目标检测中的 Precision/Recall 到底怎么算?

你这个问题问到点子上了——目标检测里的 Precision/Recall 确实不能用分类那套"预测对了/错了"直接套,因为这里没有"一个样本一个标签"的对应关系,而是一堆框对一堆框。核心难点就一个:怎么判断一个预测框算 TP、FP 还是 FN?

先解决"配对"问题

分类任务里,每个样本天然对应一个预测。检测任务里,一张图可能有 3 个真实框、模型输出 7 个预测框,数量都不一定对得上。所以第一步必须把预测框和真实框配对。

配对规则就是 IoU(交并比):
在这里插入图片描述

IoU = 预测框与真实框的交集面积 / 并集面积

Ultralytics 默认用 IoU ≥ 0.5 作为匹配阈值(这个值来自 iou 参数,但注意 iou 参数实际控制的是 NMS,验证时匹配用的阈值另有逻辑,通常按 mAP 计算流程走 0.5~0.95)。

配对流程:

  • 对每个预测框,找与它 IoU 最大的真实框
  • 如果这个最大 IoU ≥ 阈值,且类别正确,且该真实框还没被别的预测框占用 → 算 TP
  • 如果 IoU 不够,或者类别错了,或者真实框已被占用 → 算 FP
  • 配对结束后,没有被任何预测框匹配上的真实框 → 算 FN
  • 一个具体例子

    假设一张图里有 2 个真实框:GT1(人)、GT2(车)。

    模型输出 4 个预测框:

    预测框类别与 GT1 IoU与 GT2 IoU判定
    P1 0.82 0.01 TP(匹配 GT1)
    P2 0.75 0.03 FP(GT1 已被 P1 占用)
    P3 0.02 0.68 TP(匹配 GT2)
    P4 0.10 0.05 FP(IoU 不够)

    统计结果:

    • TP = 2(P1、P3)
    • FP = 2(P2、P4)
    • FN = 0(GT1、GT2 都被匹配上了)

    Precision = TP / (TP + FP) = 2 / 4 = 0.50
    Recall = TP / (TP + FN) = 2 / 2 = 1.00

    如果 GT2 没被任何框匹配上,那 FN 就变成 1,Recall = 2/3 ≈ 0.67。
    在这里插入图片描述

    目标检测的 Precision/Recall 不是靠"预测对不对"算的,而是靠 IoU 把预测框和真实框配对后,数 TP、FP、FN 算出来的:

    • 匹配上且类别对 → TP
    • 没匹配上或类别错 → FP
    • 真实框没人匹配 → FN

    然后 P = TP/(TP+FP),R = TP/(TP+FN),按置信度排序扫一遍得到 PR 曲线,mp/mr 取 F1 最大点,mAP 取曲线下面积。

    1.3 mAP50 和 mAP50-95:一个更综合的分数

    mAP 是 Average Precision 的均值,但理解它需要先理解 AP。

    AP(Average Precision) 可以粗略理解为:在遍历所有可能的置信度阈值后,Precision-Recall 曲线下的面积。AP 越高,说明模型在不同"谨慎程度"下综合表现越好。

    AP(Average Precision)计算公式详解

    AP 是目标检测中最核心的指标之一。它的名字叫"平均精确率",但实际计算的是 PR 曲线下的面积,而不是简单的算术平均。下面从公式到计算过程完整拆解。

    先明确:AP 计算的两个前提

    在计算 AP 之前,必须先固定两个东西:

  • IoU 阈值:通常取 0.5(或 0.75、0.5~0.95 等)。只有 IoU ≥ 阈值的预测才算 TP。
  • 单个类别:AP 是逐类别计算的,每个类别单独算一个 AP。
  • 基础定义:Precision 和 Recall

    在某个置信度阈值下:

    Precision=TPTP+FP
    \\text{Precision} = \\frac{TP}{TP + FP}
    Precision=TP+FPTP

    Recall=TPTP+FN
    \\text{Recall} = \\frac{TP}{TP + FN}
    Recall=TP+FNTP

    其中:

    • TP:预测框与真实框 IoU ≥ 阈值,且类别正确
    • FP:预测框没有匹配到任何真实框(或 IoU 不够)
    • FN:真实框没有被任何预测框匹配上

    注意:TP + FP = 所有预测框数量;TP + FN = 所有真实框数量(固定不变)。

    AP 的标准定义公式

    AP 的数学定义是 PR 曲线下的面积:

    AP=∫01P(R) dR
    AP = \\int_0^1 P(R) \\, dR
    AP=01P(R)dR

    其中 P(R)P(R)P(R) 是以 Recall 为横轴、Precision 为纵轴的 PR 曲线函数。

    但在实际计算中,PR 曲线是离散的(由一系列预测点组成),所以用求和代替积分。

    完整计算示例

    假设某类别有 5 个真实框,模型输出 8 个预测(已按置信度降序排列):

    序号置信度是否 TP累计 TP累计 FPPrecisionRecall
    1 0.95 TP 1 0 1.00 0.20
    2 0.90 FP 1 1 0.50 0.20
    3 0.85 TP 2 1 0.67 0.40
    4 0.80 TP 3 1 0.75 0.60
    5 0.70 FP 3 2 0.60 0.60
    6 0.65 TP 4 2 0.67 0.80
    7 0.60 FP 4 3 0.57 0.80
    8 0.55 TP 5 3 0.63 1.00

    第 1 步:绘制原始 PR 曲线

    点集:(0.20, 1.00), (0.20, 0.50), (0.40, 0.67), (0.60, 0.75), (0.60, 0.60), (0.80, 0.67), (0.80, 0.57), (1.00, 0.63)

    第 2 步:右侧最大值插值,得到单调递减曲线

    从右往左取最大 Precision:

    • R=1.00:max = 0.63
    • R=0.80:max(0.67, 0.57, 0.63) = 0.67
    • R=0.60:max(0.75, 0.60, 0.67, 0.57, 0.63) = 0.75
    • R=0.40:max(0.67, …) = 0.75
    • R=0.20:max(1.00, 0.50, 0.67, …) = 1.00
    • R=0:max = 1.00

    插值后曲线:(0, 1.00), (0.20, 1.00), (0.40, 0.75), (0.60, 0.75), (0.80, 0.67), (1.00, 0.63)

    第 3 步:计算曲线下面积(连续积分法)

    AP=(0.20−0)×1.00+(0.40−0.20)×0.75+(0.60−0.40)×0.75+(0.80−0.60)×0.67+(1.00−0.80)×0.63
    AP = (0.20-0)\\times1.00 + (0.40-0.20)\\times0.75 + (0.60-0.40)\\times0.75 + (0.80-0.60)\\times0.67 + (1.00-0.80)\\times0.63
    AP=(0.200)×1.00+(0.400.20)×0.75+(0.600.40)×0.75+(0.800.60)×0.67+(1.000.80)×0.63

    AP=0.20+0.15+0.15+0.134+0.126=0.76
    AP = 0.20 + 0.15 + 0.15 + 0.134 + 0.126 = \\boxed{0.76}
    AP=0.20+0.15+0.15+0.134+0.126=0.76

    mAP 的计算

    mAP(mean Average Precision) 就是所有类别 AP 的平均:

    mAP=1N∑i=1NAPi
    mAP = \\frac{1}{N} \\sum_{i=1}^{N} AP_i
    mAP=N1i=1NAPi

    其中 NNN 是类别总数。

    • mAP50 = 在 IoU=0.5 下,所有类别 AP 的平均
    • mAP50-95 = 在 IoU=0.5, 0.55, …, 0.95 共 10 个阈值下分别算 mAP,再平均

    AP = 把预测按置信度排序 → 逐点算 Precision/Recall → 画 PR 曲线 → 用"右侧最大值"插值平滑 → 对 Recall 积分求面积。

    mAP = 所有类别 AP 的平均。mAP50-95 = 在 10 个 IoU 阈值下重复上述过程再平均。

    mAP50:在 IoU 阈值为 0.5 时计算的 mAP。IoU = 0.5 意味着预测框和真实框只要有 50% 的重叠就算"定位正确"。这是目标检测领域最常用的基准指标。

    mAP50-95:在 IoU 阈值从 0.5 到 0.95(步长 0.05)共 10 个阈值上分别计算 mAP,然后取平均。它比 mAP50 严格得多,要求模型不仅"框住了",还要"框得准"。如果你的 mAP50 很高但 mAP50-95 很低,说明模型能检测到目标,但边界框的定位精度不够。

    mAP50 和 mAP50-95 详解

    这两个指标是目标检测中最核心的评测标准。要真正理解它们,需要先搞清楚三个概念:IoU、AP、mAP。

    先理解 IoU:判断"框得准不准"的标准

    IoU(Intersection over Union,交并比) 衡量预测框和真实框的重叠程度。

    IoU = 预测框与真实框的交集面积 / 预测框与真实框的并集面积

    • IoU = 1.0:预测框和真实框完全重合,完美
    • IoU = 0.5:重叠一半左右,大致框住了
    • IoU = 0:完全不重叠

    关键点:一个预测框是否算"检测正确"(TP),取决于 IoU 是否超过某个阈值。阈值越高,要求框得越准。

    AP:单个类别的"综合得分"

    AP(Average Precision,平均精确率) 是单个类别在某个固定 IoU 阈值下的检测质量。

    它的计算逻辑:

  • 把模型对该类别的所有预测按置信度从高到低排序
  • 逐个判断每个预测是 TP 还是 FP(依据 IoU 是否超过阈值)
  • 随着预测逐个加入,计算每一步的 Precision 和 Recall
  • 画出 PR 曲线,曲线下的面积就是 AP
  • 直观理解:AP 综合了模型在所有置信度阈值下的表现,数值越高说明该类别的检测越可靠。

    mAP50:宽松标准下的平均精度

    mAP50 = 在 IoU 阈值固定为 0.5 时,所有类别 AP 的平均值。

    公式:

    mAP50 = (AP_class1 + AP_class2 + … + AP_classN) / N

    含义:

    • 只要预测框和真实框的重叠面积超过 50%,就算检测正确
    • 这是最宽松、最常用的检测指标
    • 反映模型"能不能找到目标、大致框住"的能力

    为什么用 0.5?
    这是计算机视觉领域的传统惯例(源自 PASCAL VOC 数据集)。0.5 的阈值意味着"框住一半以上就算对",对定位精度的要求不高。

    局限:mAP50 无法区分"框得很准"和"勉强框住"的模型。两个模型可能 mAP50 相同,但实际定位质量差距很大。

    mAP50-95:严格标准下的综合精度

    mAP50-95 = 在 IoU 阈值从 0.5 到 0.95(步长 0.05)共 10 个阈值下,分别计算 mAP,再取平均。

    具体来说,它计算以下 10 个值然后平均:

    IoU=0.50 的 mAP
    IoU=0.55 的 mAP
    IoU=0.60 的 mAP

    IoU=0.90 的 mAP
    IoU=0.95 的 mAP

    含义:

    • 它要求模型在各种严格程度下都表现良好
    • IoU=0.95 意味着预测框和真实框几乎完全重合才算对——这是极难达到的
    • 因此 mAP50-95 的数值永远低于 mAP50

    为什么这样设计?
    单一阈值(如 0.5)太宽松,无法反映定位精度。mAP50-95 通过多阈值平均,逼迫模型不仅要"找到目标",还要"框得精准"。

    两者对比:一张表看懂差异
    对比项mAP50mAP50-95
    IoU 阈值 固定 0.5 0.5~0.95 共 10 个
    严格程度 宽松 严格
    数值大小 较高 较低(通常是 mAP50 的 60%~75%)
    反映能力 能否找到目标、大致框住 定位精度 + 检测稳定性
    常见用途 快速对比、论文常用 COCO 官方指标、衡量真实水平
    典型值(YOLOv8n) ~0.50 ~0.37

    关键关系:

    mAP50 ≥ mAP50-95 (恒成立)

    因为 IoU=0.5 是最宽松的条件,它的 mAP 一定是最高的。

    用具体例子理解

    假设你的模型检测两个类别:person 和 car。

    在 IoU=0.5 时:

    • person 的 AP = 0.80
    • car 的 AP = 0.60
    • mAP50 = (0.80 + 0.60) / 2 = 0.70

    在 IoU=0.5~0.95 各阈值下:

    IoU 阈值person APcar APmAP
    0.50 0.80 0.60 0.70
    0.55 0.78 0.58 0.68
    0.60 0.75 0.55 0.65
    0.95 0.30 0.15 0.225

    mAP50-95 = 这 10 个 mAP 的平均值,假设约为 0.52。

    可以看到:mAP50 = 0.70,mAP50-95 = 0.52,差距明显。这个差距反映的就是定位精度问题——模型能找到目标,但框得不够精准。

    1.4 PR 曲线怎么读

    PR 曲线以 Recall 为横轴、Precision 为纵轴。理想情况下,曲线应该尽量贴近右上角(Recall 和 Precision 都高)。

    实际曲线通常是一条从右上向左下延伸的折线。曲线下的面积就是 AP。你需要注意几个特征:

    • 曲线右侧的"尾巴":如果曲线在 Recall 接近 1 时 Precision 骤降,说明模型为了找出所有目标,不得不大量误报。
    • 曲线左侧的"肩膀":如果 Precision 在低 Recall 时就上不去,说明模型连"有把握的检测"都做不准。
    • 曲线的整体形状:一个好的模型,PR 曲线应该是一个"饱满的凸包",在 Recall 从 0 到 0.8 的区间内 Precision 能维持在较高水平。
    PR 曲线与 ROC 曲线:从原理到实战的完整解释

    在目标检测和分类任务中,PR 曲线和 ROC 曲线是评估模型性能的两大核心工具。很多初学者会混淆它们,或者只知道"曲线下面积越大越好"却不知道为什么。下面从原理到读法,把它们彻底讲清楚。

    先搞懂一个前提:阈值在变,指标在变

    所有分类模型(包括目标检测中的每个检测框)输出的是一个置信度分数(0 到 1 之间)。要把它变成"是/否"的判断,必须设定一个阈值。

    • 阈值设得高:只有非常有把握的才判为正 → 预测为正的少 → FP 少、FN 多
    • 阈值设得低:稍微有点可能就判为正 → 预测为正的多 → FP 多、FN 少

    单一阈值只能得到一个 (Precision, Recall) 点。 而 PR 曲线和 ROC 曲线,就是把所有可能的阈值都遍历一遍,画出的一条完整曲线。

    PR 曲线(Precision-Recall Curve)
    坐标轴定义
    • 横轴:Recall = TP / (TP + FN),即"真实目标中被找到的比例"
    • 纵轴:Precision = TP / (TP + FP),即"预测为正的目标中真正正确的比例"
    曲线怎么来的

    把模型对所有样本的预测按置信度从高到低排序,然后从高阈值往低阈值滑动:

    阈值预测为正的数量PrecisionRecall
    0.9 很少 很高(几乎没错) 很低(漏了很多)
    0.5 中等 中等 中等
    0.1 很多 较低(混入误检) 很高(几乎都找到)

    把每个阈值对应的 (Recall, Precision) 点连起来,就是 PR 曲线。曲线通常从右上角(高精度低召回)开始,向左下方延伸。

    • 曲线越靠右上角越好:意味着在保持高 Recall 的同时 Precision 也很高
    • 曲线下方的面积 = AP(Average Precision):AP 越大越好,mAP 就是所有类别 AP 的平均
    • 曲线出现"断崖式下跌":说明在这个 Recall 水平上,模型开始大量误检。例如曲线在 Recall=0.7 之前都很平缓,到 0.75 突然掉到 0.3,说明模型"再想多找目标,就全是错的"
    一个直观例子

    假设你有 100 个真实目标:

    • 阈值 0.9:找到 40 个,其中 39 个正确 → Precision=0.975,Recall=0.40
    • 阈值 0.5:找到 80 个,其中 70 个正确 → Precision=0.875,Recall=0.70
    • 阈值 0.1:找到 120 个,其中 75 个正确 → Precision=0.625,Recall=0.75

    把这些点画出来,就是 PR 曲线。可以看到 Recall 从 0.70 到 0.75 只涨了 5%,但 Precision 从 0.875 暴跌到 0.625——这就是曲线在该处急剧下降的原因:模型为了多找 5 个目标,多产生了 40 个误检。

    ROC 曲线(Receiver Operating Characteristic)
    坐标轴定义
    • 横轴:FPR(False Positive Rate) = FP / (FP + TN),即"所有负样本中被误判为正的比例"
    • 纵轴:TPR(True Positive Rate) = TP / (TP + FN),注意:TPR 就是 Recall

    所以 ROC 曲线的纵轴和 PR 曲线的横轴是同一个东西,但横轴换成了 FPR。

    曲线怎么来的

    同样是滑动阈值,但记录的是 (FPR, TPR):

    阈值FPRTPR
    0.9 很低 很低
    0.5 中等 中等
    0.1 很高 很高

    把点连起来,就是 ROC 曲线。曲线从左下角(0,0)到右上角(1,1),越靠近左上角越好。

    • 曲线越靠左上角越好:意味着在 FPR 很低的情况下 TPR 已经很高
    • 对角线(y=x)代表随机猜测:模型完全没有区分能力
    • AUC(Area Under Curve):曲线下面积,范围 0.5~1.0。0.5 是随机,1.0 是完美
    • AUC 的物理意义:随机取一个正样本和一个负样本,模型给正样本更高分数的概率
    PR 曲线 vs ROC 曲线:核心区别
    维度PR 曲线ROC 曲线
    横轴 Recall (TPR) FPR
    纵轴 Precision TPR (Recall)
    关注 正样本的检测质量 正负样本的区分能力
    对类别不平衡 敏感 不敏感
    适用场景 正样本稀少、关注正样本 正负样本相对均衡
    面积指标 AP / mAP AUC
    为什么说 ROC 对类别不平衡不敏感

    因为 FPR 的分母是 FP + TN,TN(真负类)通常数量巨大。即使 FP 增加很多,FPR 的变化也可能很小。

    举个例子:10000 个样本中只有 100 个正样本。

    • 模型 A:找到 80 个正样本,误检 50 个负样本 → TPR=0.8,FPR=50/9900≈0.005
    • 模型 B:找到 82 个正样本,误检 500 个负样本 → TPR=0.82,FPR=500/9900≈0.051

    在 ROC 空间里,A 和 B 的点差别不大(都很靠左上角)。但在 PR 空间里:

    • 模型 A:Precision = 80/(80+50) = 0.615
    • 模型 B:Precision = 82/(82+500) = 0.141

    Precision 差了 4 倍多! 所以当正样本稀少时,PR 曲线能暴露出 ROC 曲线掩盖的问题。

    目标检测为什么用 PR 曲线

    目标检测中,背景(负样本)数量远远多于目标(正样本)。一张 640×640 的图片可能只有几个目标,但有成千上万个候选框是背景。如果用 ROC,背景的绝对数量会稀释 FPR,让模型看起来"很好",但实际上误检可能很多。因此目标检测标准指标 mAP 是基于 PR 曲线计算的。

    • PR 曲线回答:“在尽可能多找到目标的同时,我能保持多高的准确率?”——关注正样本
    • ROC 曲线回答:“模型区分正负样本的能力有多强?”——关注整体区分度
    • 类别不平衡时用 PR,类别均衡时两者都可
    • 目标检测看 PR,因为背景远多于目标

    记住:曲线本身不告诉你"该用哪个阈值",它告诉你所有阈值下的权衡。最终选哪个阈值,取决于你的业务更怕误检还是更怕漏检。

    二、混淆矩阵:把错误拆开来看

    2.1 目标检测的混淆矩阵和分类有什么不同?

    分类任务的混淆矩阵很简单:行是真实类别,列是预测类别,对角线是正确分类。

    但目标检测多了一层"定位"的维度。检测结果不只有"类别对不对",还有"框得准不准"。所以目标检测的混淆矩阵需要先判断预测框和真实框是否匹配(通常用 IoU 阈值),匹配上的再比较类别。

    Ultralytics 的混淆矩阵中,除了各个类别的行列,还有一个特殊的 background 维度。它代表:

    • background 行 → 某类别列:模型把背景误检成了某个目标(FP,误报)
    • 某类别行 → background 列:模型漏掉了某个真实目标(FN,漏检)

    2.2 FP 和 FN:两种不同性质的错误

    FP(False Positive,假阳性/误检):模型在根本没有目标的地方画了框。比如把树影误认为是行人,把墙上的海报误认为是消防栓。

    FN(False Negative,假阴性/漏检):模型漏掉了真实存在的目标。比如图像角落的一个小目标没被检测到,或者被遮挡的车辆完全没有触发任何预测。

    这两个错误的业务含义完全不同。在安防监控中,FN(漏掉真正的入侵者)可能比 FP(误报一只猫)严重得多。在医学影像中,FN(漏掉肿瘤)的代价更是不可接受。所以你到底更关心 FP 还是 FN,取决于你的应用场景。

    三、复盘问题:现在你可以回答了

    3.1 我更关心误检还是漏检?

    这不是一个"哪个更好"的问题,而是"你的场景能容忍什么"的问题。

    如果你在做安防监控,漏检(FN)意味着可能错过真正的威胁,代价极高;误检(FP)只是浪费一些人工复核的时间。这种情况下,你更应该关注 Recall,宁可多报也不能漏报。

    如果你在做自动化质检,误检会导致正常产品被错误地判定为缺陷品,造成产线停机;漏检则意味着缺陷品流入市场。两者都致命,但通常误检的成本更容易量化。

    如果你在做自动驾驶感知,漏检一个行人可能导致事故,误检一个不存在的障碍物可能导致不必要的急刹。这种情况下需要在 Recall 和 Precision 之间寻找一个适合你安全策略的平衡点。

    在这里插入图片描述

    你的答案应该来自你的业务逻辑,而不是来自指标本身。

    3.2 哪个类别的效果最差?为什么?

    通过 metrics.box.maps 找出 mAP50-95 最低的类别,然后回到你的验证集里,专门抽出这个类别的图片看。

    最差的类别通常有这几个原因:

    • 样本量太少:如果你有 10 个类别,其中一个类别只有几十张图,模型很难学好。
    • 类内差异大:同一个类别在不同场景下外观变化剧烈(比如"椅子"有办公椅、餐椅、折叠椅),模型容易困惑。
    • 类间相似度高:两个类别长得像(比如"面包车"和"卡车"),模型容易混淆。
    • 目标尺寸特殊:如果这个类别的目标普遍偏小或偏大,而你的 anchor 或特征金字塔没有针对性优化,效果就会差。

    动手做:从 metrics.box.maps 拿到最差类别的 ID,然后在验证集中筛选出所有包含该类别的图片,逐一检查。你大概率会看到上述几种模式中的某一种在反复出现。

    实践代码:从指标到错误归因的完整实践

    基于你上一节课的 YOLOTrainer,这节课的实践代码要做三件事:

  • 读取并解读训练指标(Precision / Recall / mAP50 / mAP50-95)
  • 绘制 PR 曲线并计算 AP(手动实现,理解指标怎么来的)
  • 错误归因分析(提取 FP / FN,可视化,逐张归因)

  • 完整实践代码

    """
    第 5 次课实践:看懂评测结果——从指标到错误归因的完整实践

    依赖:
    pip install ultralytics pandas matplotlib opencv-python numpy pyyaml
    """

    import random
    import shutil
    import yaml
    import warnings
    from pathlib import Path
    from typing import Optional, List, Tuple, Dict

    import numpy as np
    import pandas as pd
    import cv2
    import matplotlib.pyplot as plt
    import matplotlib.patches as patches

    from ultralytics import YOLO

    warnings.filterwarnings('ignore')

    # 中文字体(如果系统没有 SimHei,可换成 DejaVu Sans 并去掉中文)
    plt.rcParams['font.sans-serif'] = ['SimHei', 'DejaVu Sans']
    plt.rcParams['axes.unicode_minus'] = False

    # ============================================================
    # 一、指标读取与解读
    # ============================================================

    class MetricsReader:
    """从训练结果目录读取指标,回答"模型到底行不行" """

    def __init__(self, run_dir: str = "runs/frames_daytime_single_train"):
    self.run_dir = Path(run_dir)
    self.results_csv = self.run_dir / "results.csv"
    self.best_weights = self.run_dir / "weights" / "best.pt"
    self.df: Optional[pd.DataFrame] = None

    def load(self) > pd.DataFrame:
    if not self.results_csv.exists():
    raise FileNotFoundError(f"未找到 {self.results_csv}")
    self.df = pd.read_csv(self.results_csv)
    # 去掉列名首尾空格(Ultralytics 有时会带空格)
    self.df.columns = [c.strip() for c in self.df.columns]
    return self.df

    def summary(self) > Dict[str, float]:
    """输出最佳指标摘要"""
    if self.df is None:
    self.load()

    df = self.df
    cols = {
    "P": "metrics/precision",
    "R": "metrics/recall",
    "mAP50": "metrics/mAP_0.5",
    "mAP50-95": "metrics/mAP_0.5:0.95",
    }

    result = {}
    print("\\n" + "=" * 70)
    print("📈 训练指标摘要")
    print("=" * 70)
    print(f"{'指标':<14}{'最佳值':<12}{'对应 Epoch':<12}{'最终值':<12}")
    print("-" * 70)

    for name, col in cols.items():
    if col not in df.columns:
    continue
    best_val = df[col].max()
    best_ep = df[col].idxmax()
    final_val = df[col].iloc[1]
    result[name] = best_val
    print(f"{name:<14}{best_val:<12.4f}{best_ep:<12}{final_val:<12.4f}")

    # 解读:mAP50 vs mAP50-95 的差距
    if "mAP50" in result and "mAP50-95" in result:
    gap = result["mAP50"] result["mAP50-95"]
    ratio = result["mAP50-95"] / result["mAP50"] if result["mAP50"] > 0 else 0
    print("-" * 70)
    print(f"🔍 mAP50 – mAP50-95 差距: {gap:.4f}")
    print(f"🔍 mAP50-95 / mAP50 比值: {ratio:.2%}")
    if ratio < 0.6:
    print(" ⚠️ 比值偏低,说明模型能'找到目标'但'框得不准',定位精度是瓶颈")
    elif ratio > 0.8:
    print(" ✅ 比值健康,定位精度与检出能力较为均衡")
    else:
    print(" ℹ️ 比值正常,定位精度有提升空间")

    # 解读:P 和 R 的平衡
    if "P" in result and "R" in result:
    print("-" * 70)
    print(f"🔍 Precision={result['P']:.4f}, Recall={result['R']:.4f}")
    if result["P"] result["R"] > 0.15:
    print(" ⚠️ Precision 明显高于 Recall:模型偏保守,漏检多,可考虑降阈值/加召回")
    elif result["R"] result["P"] > 0.15:
    print(" ⚠️ Recall 明显高于 Precision:模型偏激进,误检多,可考虑提阈值/加负样本")
    else:
    print(" ✅ P/R 相对平衡")

    return result

    def plot_curves(self, save_path: str = "runs/metrics_curves.png"):
    """绘制训练过程中的 P/R/mAP 曲线"""
    if self.df is None:
    self.load()

    df = self.df
    fig, axes = plt.subplots(2, 2, figsize=(12, 8))
    plot_cfg = [
    ("metrics/precision", "Precision", axes[0, 0]),
    ("metrics/recall", "Recall", axes[0, 1]),
    ("metrics/mAP_0.5", "mAP50", axes[1, 0]),
    ("metrics/mAP_0.5:0.95", "mAP50-95", axes[1, 1]),
    ]

    for col, title, ax in plot_cfg:
    if col in df.columns:
    ax.plot(df["epoch"], df[col], linewidth=2, color="#1f77b4")
    ax.set_title(title, fontsize=12)
    ax.set_xlabel("Epoch")
    ax.set_ylabel(title)
    ax.grid(alpha=0.3)
    # 标出最佳点
    best_idx = df[col].idxmax()
    ax.scatter(df["epoch"].iloc[best_idx], df[col].iloc[best_idx],
    color="red", zorder=5, s=50,
    label=f"best={df[col].iloc[best_idx]:.4f}")
    ax.legend()

    plt.tight_layout()
    Path(save_path).parent.mkdir(parents=True, exist_ok=True)
    plt.savefig(save_path, dpi=120)
    plt.close()
    print(f"📊 指标曲线已保存: {save_path}")

    # ============================================================
    # 二、手动实现 PR 曲线与 AP 计算(理解指标怎么来的)
    # ============================================================

    class ManualAPCalculator:
    """
    手动实现 AP 计算,和第一节课讲的公式一一对应:
    1. 按置信度降序排序
    2. 逐点累计 TP/FP -> Precision/Recall
    3. 右侧最大值插值平滑
    4. 对 Recall 积分求面积
    """

    @staticmethod
    def compute_ap(tp_flags: List[int], num_gt: int) > Tuple[float, np.ndarray, np.ndarray]:
    """
    Args:
    tp_flags: 按置信度降序排列的预测,1=TP,0=FP
    num_gt: 该类别的真实框总数
    Returns:
    ap, recall_curve, precision_curve
    """

    tp_flags = np.array(tp_flags, dtype=np.float32)
    cum_tp = np.cumsum(tp_flags)
    cum_fp = np.cumsum(1 tp_flags)

    precision = cum_tp / np.maximum(cum_tp + cum_fp, 1e-9)
    recall = cum_tp / max(num_gt, 1)

    # 右侧最大值插值:从右往左取最大 Precision
    precision_smooth = precision.copy()
    for i in range(len(precision_smooth) 2, 1, 1):
    precision_smooth[i] = max(precision_smooth[i], precision_smooth[i + 1])

    # 在 recall 轴上积分(矩形法)
    # recall 单调不减,用相邻 recall 差 * 当前平滑 precision
    recall_padded = np.concatenate([[0.0], recall])
    precision_padded = np.concatenate([[precision_smooth[0]], precision_smooth])
    ap = float(np.sum(np.diff(recall_padded) * precision_padded[1:]))

    return ap, recall, precision_smooth

    @staticmethod
    def demo():
    """用课程里的 8 个预测示例验证实现是否正确"""
    tp_flags = [1, 0, 1, 1, 0, 1, 0, 1] # 对应课程表格
    num_gt = 5
    ap, recall, precision = ManualAPCalculator.compute_ap(tp_flags, num_gt)
    print("\\n" + "=" * 70)
    print("🧮 手动 AP 计算验证(课程示例)")
    print("=" * 70)
    print(f"TP 序列: {tp_flags}, GT 总数: {num_gt}")
    print(f"Recall : {np.round(recall, 4)}")
    print(f"Precision: {np.round(precision, 4)}")
    print(f"AP = {ap:.4f} (课程手算结果 ≈ 0.76)")
    return ap

    @staticmethod
    def plot_pr_curves(pr_data: Dict[str, Tuple[np.ndarray, np.ndarray]],
    save_path: str = "runs/pr_curves_manual.png"):
    """
    pr_data: {class_name: (recall, precision)}
    """

    plt.figure(figsize=(7, 6))
    colors = plt.cm.tab10(np.linspace(0, 1, len(pr_data)))

    for (name, (recall, precision)), color in zip(pr_data.items(), colors):
    # 首尾补齐,画成闭合曲线
    r = np.concatenate([[0.0], recall, [1.0]])
    p = np.concatenate([[precision[0]], precision, [0.0]])
    plt.plot(r, p, linewidth=2, color=color, label=f"{name}")

    plt.xlabel("Recall", fontsize=12)
    plt.ylabel("Precision", fontsize=12)
    plt.title("PR 曲线(手动计算)", fontsize=13)
    plt.xlim(0, 1)
    plt.ylim(0, 1.05)
    plt.grid(alpha=0.3)
    plt.legend()
    plt.tight_layout()
    Path(save_path).parent.mkdir(parents=True, exist_ok=True)
    plt.savefig(save_path, dpi=120)
    plt.close()
    print(f"📈 PR 曲线已保存: {save_path}")

    # ============================================================
    # 三、错误归因分析:提取 FP / FN 并可视化
    # ============================================================

    class ErrorAnalyzer:
    """
    在验证集上跑预测,逐图匹配 GT 和预测,提取:
    – FP: 误检(无匹配 GT 或类别错)
    – FN: 漏检(无匹配预测的 GT)
    – 定位不准: IoU 在 [0.1, 0.5) 之间的预测(找到了但框歪了)
    并可视化保存,用于人工归因。
    """

    def __init__(
    self,
    weights: str = "runs/frames_daytime_single_train/weights/best.pt",
    data_yaml: str = "runs/frames_daytime_single_train/data.yaml",
    conf_thres: float = 0.25,
    iou_thres: float = 0.5,
    device: str = "cpu",
    ):
    self.weights = weights
    self.data_yaml = Path(data_yaml)
    self.conf_thres = conf_thres
    self.iou_thres = iou_thres
    self.device = device

    with open(self.data_yaml, "r", encoding="utf-8") as f:
    cfg = yaml.safe_load(f)
    self.dataset_root = Path(cfg["path"])
    self.val_images_dir = self.dataset_root / cfg["val"]
    self.val_labels_dir = Path(str(self.val_images_dir).replace("images", "labels"))
    self.class_names = cfg["names"]

    self.model = YOLO(weights)

    # ———- IoU 与匹配 ———-
    @staticmethod
    def _xywh2xyxy(box):
    x, y, w, h = box
    return [x w / 2, y h / 2, x + w / 2, y + h / 2]

    @staticmethod
    def _iou_xyxy(a, b):
    xa1, ya1, xa2, ya2 = a
    xb1, yb1, xb2, yb2 = b
    inter_x1, inter_y1 = max(xa1, xb1), max(ya1, yb1)
    inter_x2, inter_y2 = min(xa2, xb2), min(ya2, yb2)
    inter_w = max(0.0, inter_x2 inter_x1)
    inter_h = max(0.0, inter_y2 inter_y1)
    inter = inter_w * inter_h
    area_a = max(0.0, xa2 xa1) * max(0.0, ya2 ya1)
    area_b = max(0.0, xb2 xb1) * max(0.0, yb2 yb1)
    union = area_a + area_b inter + 1e-9
    return inter / union

    def _load_gt(self, label_path: Path, img_w: int, img_h: int):
    """读取 YOLO 格式 GT,返回 [(cls, xyxy_pixel), …]"""
    gts = []
    if not label_path.exists():
    return gts
    for line in label_path.read_text().strip().splitlines():
    parts = line.split()
    if len(parts) < 5:
    continue
    cls = int(float(parts[0]))
    cx, cy, w, h = map(float, parts[1:5])
    # 归一化 -> 像素 xyxy
    x1 = (cx w / 2) * img_w
    y1 = (cy h / 2) * img_h
    x2 = (cx + w / 2) * img_w
    y2 = (cy + h / 2) * img_h
    gts.append((cls, [x1, y1, x2, y2]))
    return gts

    # ———- 单图匹配 ———-
    def _match_one_image(self, img_path: Path):
    """
    返回 dict:
    fp: [(cls, xyxy, conf, reason), …]
    fn: [(cls, xyxy), …]
    loc_bad: [(cls, xyxy, conf, iou), …] 定位不准
    """

    img = cv2.imread(str(img_path))
    h, w = img.shape[:2]
    gt_boxes = self._load_gt(
    self.val_labels_dir / f"{img_path.stem}.txt", w, h
    )
    gt_used = [False] * len(gt_boxes)

    results = self.model.predict(
    source=str(img_path),
    conf=self.conf_thres,
    iou=self.iou_thres,
    device=self.device,
    verbose=False,
    )[0]

    preds = []
    if results.boxes is not None and len(results.boxes) > 0:
    boxes_xyxy = results.boxes.xyxy.cpu().numpy()
    confs = results.boxes.conf.cpu().numpy()
    clss = results.boxes.cls.cpu().numpy().astype(int)
    order = np.argsort(confs) # 按置信度降序
    for i in order:
    preds.append((clss[i], boxes_xyxy[i].tolist(), float(confs[i])))

    fp, loc_bad = [], []
    for cls, box, conf in preds:
    best_iou, best_j = 0.0, 1
    for j, (gcls, gbox) in enumerate(gt_boxes):
    if gt_used[j]:
    continue
    iou = self._iou_xyxy(box, gbox)
    if iou > best_iou:
    best_iou, best_j = iou, j

    if best_j >= 0 and best_iou >= self.iou_thres:
    gcls = gt_boxes[best_j][0]
    if gcls == cls:
    gt_used[best_j] = True # TP
    else:
    fp.append((cls, box, conf, f"类别错(预测{self.class_names[cls]},真{self.class_names[gcls]})"))
    gt_used[best_j] = True # 类别错也算占用(避免重复统计)
    elif best_j >= 0 and best_iou >= 0.1:
    # 找到了但框歪
    loc_bad.append((cls, box, conf, best_iou))
    gt_used[best_j] = True
    else:
    fp.append((cls, box, conf, "纯背景误检"))

    fn = [(gt_boxes[j][0], gt_boxes[j][1]) for j in range(len(gt_boxes)) if not gt_used[j]]
    return {"fp": fp, "fn": fn, "loc_bad": loc_bad, "img": img}

    # ———- 可视化 ———-
    @staticmethod
    def _draw(img, fp, fn, loc_bad, class_names, title):
    vis = img.copy()
    # GT 漏检 -> 红色虚线框
    for cls, box in fn:
    x1, y1, x2, y2 = map(int, box)
    cv2.rectangle(vis, (x1, y1), (x2, y2), (0, 0, 255), 2)
    cv2.putText(vis, f"FN:{class_names[cls]}", (x1, max(0, y1 5)),
    cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1)
    # FP -> 橙色框
    for cls, box, conf, reason in fp:
    x1, y1, x2, y2 = map(int, box)
    cv2.rectangle(vis, (x1, y1), (x2, y2), (0, 165, 255), 2)
    cv2.putText(vis, f"FP:{class_names[cls]} {conf:.2f}", (x1, max(0, y1 5)),
    cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 165, 255), 1)
    # 定位不准 -> 紫色框
    for cls, box, conf, iou in loc_bad:
    x1, y1, x2, y2 = map(int, box)
    cv2.rectangle(vis, (x1, y1), (x2, y2), (128, 0, 128), 2)
    cv2.putText(vis, f"LOC:{class_names[cls]} IoU={iou:.2f}", (x1, max(0, y1 5)),
    cv2.FONT_HERSHEY_SIMPLEX, 0.5, (128, 0, 128), 1)
    cv2.putText(vis, title, (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2)
    return vis

    # ———- 主流程 ———-
    def run(self, max_images: int = 50, save_dir: str = "runs/error_analysis"):
    save_dir = Path(save_dir)
    (save_dir / "FP").mkdir(parents=True, exist_ok=True)
    (save_dir / "FN").mkdir(parents=True, exist_ok=True)
    (save_dir / "LOC").mkdir(parents=True, exist_ok=True)

    img_paths = sorted(
    [p for p in self.val_images_dir.rglob("*")
    if p.suffix.lower() in (".jpg", ".jpeg", ".png", ".bmp")]
    )
    random.seed(42)
    random.shuffle(img_paths)
    img_paths = img_paths[:max_images]

    records = []
    total_fp = total_fn = total_loc = 0

    for img_path in img_paths:
    r = self._match_one_image(img_path)
    n_fp, n_fn, n_loc = len(r["fp"]), len(r["fn"]), len(r["loc_bad"])
    total_fp += n_fp
    total_fn += n_fn
    total_loc += n_loc
    records.append({
    "image": img_path.name,
    "FP": n_fp,
    "FN": n_fn,
    "LOC_BAD": n_loc,
    })

    title = f"{img_path.name} FP={n_fp} FN={n_fn} LOC={n_loc}"
    vis = self._draw(r["img"], r["fp"], r["fn"], r["loc_bad"],
    self.class_names, title)
    out = save_dir / "all" / f"{img_path.stem}_err.jpg"
    out.parent.mkdir(parents=True, exist_ok=True)
    cv2.imwrite(str(out), vis)

    # 按错误类型单独归档,便于归类复盘
    if n_fp > 0:
    cv2.imwrite(str(save_dir / "FP" / f"{img_path.stem}.jpg"), vis)
    if n_fn > 0:
    cv2.imwrite(str(save_dir / "FN" / f"{img_path.stem}.jpg"), vis)
    if n_loc > 0:
    cv2.imwrite(str(save_dir / "LOC" / f"{img_path.stem}.jpg"), vis)

    # 汇总表
    df = pd.DataFrame(records).sort_values(["FN", "FP"], ascending=False)
    df.to_csv(save_dir / "error_summary.csv", index=False, encoding="utf-8-sig")

    print("\\n" + "=" * 70)
    print("🔎 错误归因汇总")
    print("=" * 70)
    print(f"分析图片数: {len(img_paths)}")
    print(f"FP (误检) 总数: {total_fp}")
    print(f"FN (漏检) 总数: {total_fn}")
    print(f"LOC(定位不准)总数: {total_loc}")
    print(f"平均每图: FP={total_fp/max(len(img_paths),1):.2f} "
    f"FN={total_fn/max(len(img_paths),1):.2f} "
    f"LOC={total_loc/max(len(img_paths),1):.2f}")
    print("-" * 70)
    print("错误最多的 Top 10 图片:")
    print(df.head(10).to_string(index=False))
    print(f"\\n📁 可视化结果: {save_dir.resolve()}")
    print(f"📄 汇总表: {save_dir / 'error_summary.csv'}")
    return df

    # ============================================================
    # 四、一键入口
    # ============================================================

    if __name__ == "__main__":
    RUN_DIR = "runs/frames_daytime_single_train"

    # ———- Step 1: 读指标 ———-
    reader = MetricsReader(run_dir=RUN_DIR)
    reader.load()
    reader.summary()
    reader.plot_curves(save_path=f"{RUN_DIR}/metrics_curves.png")

    # ———- Step 2: 手动 AP 计算验证 ———-
    ManualAPCalculator.demo()

    # ———- Step 3: 错误归因 ———-
    analyzer = ErrorAnalyzer(
    weights=f"{RUN_DIR}/weights/best.pt",
    data_yaml=f"{RUN_DIR}/data.yaml",
    conf_thres=0.25,
    iou_thres=0.5,
    device="cpu",
    )
    analyzer.run(max_images=50, save_dir=f"{RUN_DIR}/error_analysis")


    运行后你会得到什么

    输出路径回答什么问题
    指标摘要 控制台 模型"行不行",P/R 是否平衡,定位精度是否是瓶颈
    指标曲线 runs/…/metrics_curves.png 训练是否收敛、何时最佳、有无过拟合
    手动 AP 验证 控制台 亲手复现课程里的 0.76,理解 AP 到底怎么算的
    PR 曲线 runs/pr_curves_manual.png 每个类别的"综合得分"形状
    错误可视化 runs/…/error_analysis/all/*.jpg 每张图的 FP(橙)/ FN(红)/ 定位不准(紫)
    分类归档 error_analysis/FP/、FN/、LOC/ 按错误类型批量复盘
    汇总表 error_analysis/error_summary.csv 哪张图错最多,优先看哪些图

    如何使用这份结果做归因(对应课程第三节)

  • 先看 summary:如果 mAP50-95 / mAP50 < 0.6,说明瓶颈在定位,重点看 LOC/ 文件夹。
  • 再看 error_summary.csv:按 FN 降序,前 10 张图优先人工看。
  • 打开 FN/ 文件夹:漏检集中在什么场景?小目标?遮挡?夜间?→ 对应补数据 / 调 imgsz / 加 mosaic。
  • 打开 FP/ 文件夹:误检集中在哪类背景?→ 加负样本 / 提高 conf。
  • 打开 LOC/ 文件夹:框歪的是大目标还是小目标?→ 检查 anchor / 回归损失。
  • 四、写在最后

    这节课的核心不是"跑通 model.val()",而是建立从数字到问题的映射能力。

    mAP 从 0.6 涨到 0.65 是一个数字变化。但当你手动看完 10 张图、标出 5 个 FP 和 5 个 FN、写下每一个错误的原因之后,你才真正知道下一步该做什么:是去补充标注、增加某个类别的样本、还是调整数据增强策略来应对遮挡和小目标。

    数据质量决定模型上限,标注归因决定你能多快靠近那个上限。

    赞(0)
    未经允许不得转载:171主机测评 » YOLO 完全指南(五):看懂评测结果
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址