资讯详情

语义分割逐类mIoU:混淆矩阵、评估口径与多卡实现

📅 2026/10/24 1:56:01 | 华诺云谱 👁 阅读
语义分割逐类mIoU:混淆矩阵、评估口径与多卡实现
简介面向语义分割任务评测环节的轻量级 Python 脚本工具聚焦测试阶段各类别 mIoU 与整体 mIoU 的统计计算适合已能跑通分割模型、需要对预测结果做定量评估的算法学习者与工程实践者使用。包内共 2 个 py 文件压缩包约 4KB体积虽小但分工明确demo_mIoU.py 负责生成 8 位预测图mIoU.py 则将其与标注 mask 逐类比对输出每个类别的 IoU 值以及整个测试集的平均 mIoU两者均可在 PyCharm 中直接运行无需额外配置。目前已有 7163 人学习下载。借助这套脚本读者可省去从零编写评测代码的时间快速得到可信的分割精度指标直观看出模型在哪些类别上表现偏弱为后续调参、类别不平衡处理与结果复现提供依据也便于将评测流程嵌入自己的训练验证链路。1. 各类别 mIoU 为什么比总体 mIoU 更能暴露模型短板一个五分类的语义分割模型跑完验证集总体 mIoU 报 0.78看上去可以交付把各类别 mIoU 拆开路面 0.91、建筑 0.85、车辆 0.72、行人 0.41、骑行者 0.06。平均值把一个基本失效的类别藏得干干净净而这恰恰是上线后最先出问题的那一类。语义分割里的各类别 mIoU本质就是把混淆矩阵按类别拆成 TP、FP、FN再逐类算一次 IoU它回答的不是整体准不准而是哪一类在拖后腿、拖到什么程度、值不值得为它补数据或改损失函数。做遥感地物提取、医学影像分割、工业缺陷分割的团队验收单上写的往往就是逐类指标而不是那个平均值。下面从混淆矩阵这笔账讲起一路落到能在自己工程里对得上数的实现和口径。2. 混淆矩阵是各类别 mIoU 的唯一账本一次累加算清 TP、FP、FN2.1 C[i][j] 到底记了什么一行一列就是一个类的全部命运设类别数为 K混淆矩阵 C 是一个 K×K 的整数矩阵约定行是标签ground truth列是预测prediction。C[i][j] 的含义只有一句话真值为第 i 类、被模型判成第 j 类的像素个数。这一句话把后面所有指标都决定了第 i 行的行和是真值为 i 类的像素总数第 j 列的列和是被模型判成 j 类的像素总数对角线上那一串 C[c][c]就是每一类的真正例 TP。这样记账的好处是同一份矩阵可以同时算出 IoU、召回、精确率、像素准确率不需要为了每个指标再扫一遍几百万像素的预测结果。工程上一个 epoch 的验证集动辄几千万像素逐类分别统计 TP/FP/FN 意味着你要遍历 K 次或者写 K 个布尔掩码内存和耗时都会翻倍而混淆矩阵只扫一次用一次索引压缩就能把二维下标 (gt, pred) 拍平成一维交给 bincount 去数。矩阵的存储开销也完全可以接受。K150 的极端多分类场景K²22500 个 int64 只占 180KB即使 K1000 也才 8MB。相比之下把每张图的预测都缓存下来再统一算才是真正吃显存的做法。用整数累加而不是浮点是另一个容易被忽略的点。验证集要跑上万张图如果每一步都用 float32 累加几十万量级的计数尾数位会慢慢吃掉精度尤其在类别极度不平衡、某一类只有几百像素的时候这种误差足以让最后一两位小数对不上。整数累加不存在这个问题也方便多卡之间做 all_reduce 求和——整数求和是精确的。2.2 IoU、Recall、Precision 在混淆矩阵上的统一写法拿到矩阵之后每一类的四个量是一步到位的。TP 是 C[c][c]FN漏检是第 c 行减去对角线也就是真是 c 但没被判成 cFP误检是第 c 列减去对角线也就是不是 c 却被判成 cTN 在语义分割里几乎不用因为它是其他所有类的组合没有实际解释意义。指标公式基于 C在语义分割里说明什么TP_cC[c][c]该类判对的像素FN_csum_j C[c][j] − C[c][c]该类被漏判成别的类行方向和FP_csum_i C[i][c] − C[c][c]别的类被误判成该类列方向和Union_cTP_c FP_c FN_c该类参与并集的全部像素IoU_cTP_c / Union_c该类区域重叠度最常用的交付指标Recall_cTP_c / (TP_c FN_c)该类召回反映漏检Precision_cTP_c / (TP_c FP_c)该类精确率反映误检mIoUmean_c(IoU_c)各类别等权平均有一处容易写错Union 不是 TPFP也不是 TPFN而是三者之和等价于行和 列和 − 对角线。因为 TP 在行和与列和里各被算了一次减掉一次才不重复。手写代码时把 Union 写成 TPFP指标会系统性地偏高而且类别越不平衡偏得越多这是血泪经验里最常见的一种。还要记住 mIoU 是 macro 平均每个类别权重相同与它有多少像素无关。这意味着一个只占 0.5% 像素的稀有类和占 60% 的背景对最终 mIoU 的贡献是一样的。这既是 mIoU 的价值逼迫模型照顾小类也是它的陷阱小类样本极少时指标方差极大一个 batch 的不同就能让 mIoU 跳三个点。与之相对像素准确率 aAcc 是 micro 平均被大类主导两者一起看才完整。2.3 逐图平均和全量累加差多少一个两图两类的反例很多人第一次自己实现各类别 mIoU会写成每张图算一次 IoU最后对图片求平均。这个算法能跑通但算出来的数和你想要的不是同一个东西。构造一个两类A、B、两张图的小例子。图 1 里 A 类 1 个像素全部判对B 类 10 个像素全部判成 A图 2 里 A 类 10 个像素全部判成 BB 类 1 个像素全部判对。逐图计算图 1 的 IoU_A1/11.0IoU_B0/100图 2 的 IoU_A0/100IoU_B1/11.0。对图片平均后IoU_A(10)/20.5IoU_B0.5mIoU0.5。换成全量累加A 类累计 TP1、FP9、FN1Union11IoU_A≈0.091B 类同理 IoU_B≈0.091mIoU≈0.091。同一批预测两种算法差了五倍多。差异的根源是权重。逐图平均给每张图相同的权重哪怕那张图里这一类只有一个像素全量累加按像素加权像素多的样本话语权大。学术界和工业界的语义分割 mIoU 默认指后者即在整个验证集上累加混淆矩阵再一次性求商这也叫 dataset-level mIoU。你在论文里看到的、在交付报告里被验收的都是这个口径。反过来逐图 IoU 并非没用。它衡量的是单张图上的稳定性做 badcase 分析时非常有用——你可以按图排序找出那些小目标全丢的样本。但它是诊断工具不是交付指标两者不要混在同一张报表里。3. 用 NumPy 和 PyTorch 各写一遍能对账的最小实现3.1 用 bincount 一行构造混淆矩阵先写不依赖任何分割框架的裸版本方便在任何环境里对账。import numpy as np def build_confusion_matrix(gt, pred, num_classes, ignore_index255): gt / pred: 任意形状的整型数组展平后逐像素统计 gt np.asarray(gt).reshape(-1).astype(np.int64) pred np.asarray(pred).reshape(-1).astype(np.int64) # 1) 无效标签先剔除它们既不算 TP也不该进任何分母 valid gt ! ignore_index # 2) 防御性检查预测越界有的后处理会用 255 表示无预测 valid (gt 0) (gt num_classes) valid (pred 0) (pred num_classes) gt, pred gt[valid], pred[valid] # 3) 二维下标压成一维idx i * K j idx gt * num_classes pred # 4) minlength 必须给否则空类别会让 reshape 错位 cm np.bincount(idx, minlengthnum_classes * num_classes) return cm.reshape(num_classes, num_classes)这段的核心是第 3 步的索引压缩。gt 和 pred 都是 [0, K) 的整数把 gt 乘上 K 再加 pred就得到了一个取值范围在 [0, K²) 的整数两个像素只要 (gt, pred) 相同就落进同一个桶bincount 直接给出计数。整个过程是一次线性扫描加一次计数复杂度 O(N)比 K 次布尔索引快一个数量级。参数上要注意三点。num_classes 必须是标签映射之后的真实类别数不是标注文件里的最大 id 加一——如果类别 id 不连续比如 0、1、5、7必须先重映射成 0..K−1否则第 4 步的 minlength 和 reshape 会把矩阵撑出一个巨大的稀疏结构还容易越界。ignore_index 默认 255 是社区惯例如果你的数据集用 0 表示忽略一定要显式传进去。minlength 不能省某个类别在这批数据里完全没出现时bincount 默认只返回到最大下标为止长度不足会导致 reshape 报错或行列错位。还有一处值得较真valid 里同时检查了 pred 的范围。严格来说预测值越界本身就是 bug应该报错而不是静默丢弃但在实际流水线里很多模型的后处理会产出 255 表示置信度不足直接丢弃比让 bincount 越界崩掉更可控。折中做法是丢弃的同时累计一个计数器验证集跑完后打印出来如果这个数不为零说明上游有问题。3.2 从混淆矩阵算各类别 IoU 与计数def per_class_report(cm, class_namesNone): cm cm.astype(np.int64) tp np.diag(cm) fp cm.sum(axis0) - tp # 列和 - 对角线误检 fn cm.sum(axis1) - tp # 行和 - 对角线漏检 union tp fp fn # 并集不是 tp fp iou np.divide(tp, union, outnp.full(len(tp), np.nan), whereunion 0) recall np.divide(tp, tp fn, outnp.full(len(tp), np.nan), where(tp fn) 0) precision np.divide(tp, tp fp, outnp.full(len(tp), np.nan), where(tp fp) 0) report { per_class_iou: iou, per_class_recall: recall, per_class_precision: precision, gt_pixels: cm.sum(axis1), # 每类真值像素数用来判断指标可信度 pred_pixels: cm.sum(axis0), miou: float(np.nanmean(iou)), # 口径 A全部类别参与 miou_present: float(np.nanmean(iou[cm.sum(axis1) 0])), # 口径 B只算 GT 出现过的类 } if class_names is not None: report[rows] [ (class_names[c], int(tp[c]), int(fp[c]), int(fn[c]), float(iou[c])) for c in range(len(tp)) ] return report这里用了 np.divide 的 where 参数而不是先除再裁原因是直接相除会触发除零警告再叠一个 np.where 又会把 nan 洗成 0指标就被悄悄改了口径。用 out 预填 nan、where 控制计算范围是一次写对的省事做法。返回值里一定要带 gt_pixels这是判断这个 0.62 到底可不可信的唯一依据。某一类真值只有 800 个像素、分成 12 张图它的 IoU 波动范围可能有 ±0.15这种情况下把它拿去和别的模型比高低基本是玄学。我自己出报表时会在 IoU 后面加一列像素数低于 5000 像素的类别单独标注样本不足仅供参考能省掉很多无谓的争论。后面两个汇总口径要分清。miou 把矩阵里的全部 K 个类都算进去包括那些 GT 里一个像素都没有的空类——它们的 IoU 是 nannanmean 会跳过。miou_present 只在 GT 出现过的类上平均。两者在标准数据集上通常一致因为每类都有样本但一旦你的验证集是切片的、或者有类被采样漏掉数值就会分叉。这也是后面排查章节里最常见的对不上数的来源。3.3 GPU 上累加、只在最后同步一次验证阶段最大的性能陷阱不是算 IoU而是把张量从显存搬到内存。每个 batch 都.cpu().numpy()然后.item()取标量会把流水线拖成串行一张卡上损失 30% 以上的吞吐并不稀奇。import torch class ConfusionMatrixMeter: def __init__(self, num_classes, ignore_index255): self.K num_classes self.ignore_index ignore_index # 直接放在 GPU 上累加整数不会丢精度 self.cm torch.zeros(num_classes * num_classes, dtypetorch.int64) torch.no_grad() def update(self, gt, pred): if self.cm.device ! pred.device: self.cm self.cm.to(pred.device) gt gt.reshape(-1).to(torch.int64) pred pred.reshape(-1).to(torch.int64) valid (gt ! self.ignore_index) (gt 0) (gt self.K) idx gt[valid] * self.K pred[valid] # bincount 在 CUDA 上同样可用全程不离开显存 self.cm torch.bincount(idx, minlengthself.K * self.K) torch.no_grad() def compute(self): cm self.cm.reshape(self.K, self.K).to(torch.float64) tp torch.diag(cm) union cm.sum(0) cm.sum(1) - tp iou torch.where(union 0, tp / union.clamp(min1.0), torch.full_like(tp, float(nan))) return iou.cpu(), cm.cpu()update 里没有任何同步点整段都在 GPU 上完成只有 compute 被调用时才把 K² 个数搬回 CPU一次验证跑完只发生一次。这里把 cm 转成 float64 再算是为了在极多像素累加后除法仍然精确K² 个元素转 float64 的开销可以忽略。一个副作用要提防torch.bincount 要求输入非负如果 pred 里混进了负数有些模型输出未做 argmax 而直接传了 logits会直接抛错。所以 update 里对 gt 做了范围检查但对 pred 没有——真出问题时报错反而是好事能逼你去查上游的 argmax 到底在哪一步丢了。3.4 多卡验证集all_reduce 之后再算不要在每张卡上各算一遍单卡跑通之后多卡验证几乎必踩一个坑每张卡各自算 mIoU再取平均。这个结果和单卡不一致而且卡越多偏得越离谱。import torch.distributed as dist def sync_confusion_matrix(cm): cm: 一维 int64 tensor位于当前 rank 的 GPU 上 if dist.is_available() and dist.is_initialized(): # NCCL 后端只支持 GPU 张量先确保在 cuda 上 dist.all_reduce(cm, opdist.ReduceOp.SUM) return cm正确顺序是每张卡各自累加本地混淆矩阵 → all_reduce 求和 → 只在 rank 0 上用合并后的矩阵算一次各类别 IoU 和 mIoU。原因是 IoU 是一个分式分子分母都必须在全量数据上先汇总先除后加在数学上就错了各卡遇到的类别分布不同时误差会被放大。这和 BatchNorm 的统计量要跨卡同步是一个道理。实现细节上all_reduce 放在验证循环结束时调用一次别每个 batch 同步一次那样通信开销会盖过计算。DDP 下记得判dist.is_initialized()单卡调试时这段代码要能安静地跳过。另外 rank 0 拿到结果后如果需要写日志用dist.barrier()让其他 rank 等待避免进程组提前销毁导致挂起。4. ignore_index、空分母与类别缺失三个必须提前定死的口径4.1 ignore_index 要在进混淆矩阵之前抹掉标注里的忽略区域边界带、未标注块、无效像素必须在校验阶段就剔除而不是靠事后从指标里减。原因很直接如果忽略像素参与了预测它会被判成某个真实类别从而污染那一类的 FP如果只在算完之后手工扣掉你根本不知道它被算进了哪一类。常见的三种忽略来源要区别对待。第一种是标签里显式标注的 ignore 值直接按值剔除即可。第二种是预测越界前面提过属于上游 bug丢弃的同时要计数。第三种是数据集自带的背景类被当作忽略很多公开数据集把 0 号类设为无效这时需要在加载阶段把标签做一次减一重映射让有效类从 0 开始连续排列否则混淆矩阵里会多出一整行一整列全是零的幽灵类把 mIoU 的分母变大。有一个隐蔽的坑某些框架在做数据增强随机裁剪、旋转时用 0 填充图像边界但标签用 255 填充如果你的 ignore_index 设成了 0 而不是 255那么被填进来的边界会全部被当成有效背景参与统计指标会莫名其妙地偏高一点点且只在用了增强的验证集上出现。这类问题不打印混淆矩阵的行和几乎发现不了。4.2 分母为零的类记 nan、记 0 还是直接剔除当某一类的 TPFPFN 为 0也就是 GT 里没有它、预测里也没有它0/0 是个未定义值。三种处理方式在业界都有人用结果能差出好几个点。记 nan 再用 nanmean 汇总是最常见的做法语义上也最合理这一类的表现无从判断不该参与平均。记 0 则会把它当成完全失败类别数越多、验证集切片越小分数被拉得越低。直接剔除和记 nan 在数值上等价区别只在日志里还能不能看到这个类。真正要小心的是分母为 0 但 GT 里其实有的情况——如果某类 GT 有像素、预测一个都没有union 不为零IoU 老老实实是 0这时候不该被 nan 逻辑跳过。反过来GT 没有但预测出了一堆FP 不为零union 也不为零IoU 同样是 0。这两种都是实打实的问题必须出现在报表里。我的习惯是把三种情况分开计数空类GT 和预测都没有、纯漏检GT 有、预测无、纯误检GT 无、预测有各自打一行日志。跑完一次验证只要纯误检的类数量不为零就说明模型在幻觉出训练集里没有的东西值得单独查一下。4.3 只在 GT 出现 / 只在预测出现的类怎么记这两个场景在标准数据集的全量验证集上很少见在做交叉验证、按场景切片评估、或者增量学习时却非常普遍。只在 GT 出现的类IoU 必然是 0会实打实拉低 mIoU。如果这类样本在你的切片里占比不小得到的 mIoU 会比全量验证低很多别急着以为模型崩了先看是不是切片里混进了训练时没见过的类。只在预测出现的类情况更微妙。它的 union 等于 FP 数IoU 为 0而且这一类的存在本身就是误检。但如果它不在你的类别表里——比如模型输出 12 类、GT 只有 10 类——那就变成了越界必须在进矩阵前过滤否则 K 会算错。工程上的做法是固定类别表让 GT 和预测共用同一份 id 映射并在评估入口断言两者的取值集合都在 [0, K) 内。这个断言很便宜却能挡掉一大类指标突然变得很奇怪的事故。4.4 评估分辨率与 reduce_zero_label 两个隐形开关还有一个不动代码就发现不了的口径差异评估在哪个分辨率上做。有些流水线为了省时间把 logits 下采样到 1/4 再 argmax然后上采样回原图尺寸和 GT 比这种做法对小目标极不友好边界像素会被系统性抹平mIoU 通常比全分辨率评估低 1~3 个点而可视化出来的图看上去差不多。如果两个模型的评估分辨率不同比较就没有意义。另一个开关是是否把 0 号类当成忽略。同一份预测在开启这个开关的评估脚本里和没开启的脚本里mIoU 可能差 5 个点以上因为背景类通常是 IoU 最高的那一类把它剔出去平均分数会掉反之如果背景本来就学得差剔出去分数反而涨。这类开关一般藏在配置文件的 dataset 段里默认值因框架而异接手别人的工程时第一件事就是把它翻出来确认。结论只有一句任何跨模型、跨版本的指标对比先把评估分辨率、ignore 策略、空类处理这三项对齐再去比数字。对不齐的对比不如不做。5. 避坑与排查各类别 mIoU 和框架日志对不上的 5 个原因5.1 自己算的比日志高几个点空类被 nanmean 悄悄剔了现象自己写的脚本算出 mIoU 0.7412训练日志里是 0.7085各类别 IoU 却逐个对得上。原因汇总口径不同。日志侧用的是全部类别参与、空类记 0的算法你的脚本用 nanmean 跳过了空类。类别数越多、验证集越小这个差异越大。解决不要猜直接把两边的每类 IoU 打出来逐个比对找到第一个不一致的类看它的 union 是不是 0。确认之后统一口径我一般固定用 nanmean 并在报告里注明参与平均的类别数比如 mIoU(47/50 类)让别人一眼看出有几个类是空的。5.2 同一个模型跑两次mIoU 差一个点现象权重没变、数据没变重跑验证脚本mIoU 从 0.712 变成 0.703。原因验证 dataloader 开了 shuffle而且用了 drop_last。某些批次被丢掉参与统计的像素集合每次都不同如果被丢掉的那批恰好含大量小类样本指标就会抖动。另一种可能是数据增强在验证阶段没关随机裁剪改变了像素构成。解决验证集一律 shuffleFalse、drop_lastFalse增强配置走独立的 eval pipeline只保留 resize 和归一化。这条看起来是常识但我在三个不同项目里都见过验证阶段还在随机翻转的配置。5.3 单卡和四卡结果不一致现象单卡 mIoU 0.72四卡 DDP 跑出来 0.69卡数越多越低。原因每张卡各自算 mIoU 再平均。IoU 是分式先除后加在数学上就错了各卡见到的类别分布不同时偏差被放大。解决改成累加混淆矩阵再 all_reduce只在 rank 0 上算一次。改完之后单卡和多卡应该能对到小数点后四位如果还对不上检查 sampler 是不是 DistributedSampler 且没有重复采样漏设set_epoch会导致每轮顺序相同影响不大但会让切片偏差固化。5.4 某一类 IoU 恒定是 0 或恒定是 1.0现象骑行者类 IoU 从头到尾都是 0或者某一类永远是 1.0训练再久也不动。原因大概率是标签映射错了。常见的有三种类别 id 不连续0、1、5、7导致矩阵空洞标签减一的偏移量在训练和评估两侧不一致某一类的标注在预处理时被整体当成 ignore 抹掉了。恒定 1.0 则往往是这一类在 GT 里根本不存在而预测也从未输出它nan 被某处填成了 1。解决打印混淆矩阵的完整行和与列和。行和为 0 说明 GT 里没有这个类检查标注加载路径行和正常但对角线为 0说明模型从未判对检查类别权重和 loss 的 ignore 设置。这一步不要靠猜矩阵本身就是黑匣子的读数。5.5 可视化看着挺好指标就是上不去现象随手抽几张图叠加显示边界贴合得不错但 mIoU 只有 0.6 出头。原因抽样的图往往来自模型擅长的场景。真实情况可能是长尾类别大面积失败而这些类在随机抽的十张图里恰好没出现。另一个可能是评估时的上采样用了双线性而 GT 是硬边界插值出来的软边界 argmax 后会产生系统性偏移。解决抽样要按类抽从每一类的 FN 里各取 top-3 最差的样本看图。上采样统一用最近邻或者干脆在低分辨率上算 loss、在全分辨率上算指标两者不要混。做完这两步通常能立刻定位到问题在数据侧还是评估侧。6. 让各类别 mIoU 落到具体样本上per-image 账本与逐类偏置校准汇总的混淆矩阵告诉你哪一类差但不告诉你差在哪些图上。补一层 per-image 账本成本很低records [] for name, gt, pred in val_loader: cm build_confusion_matrix(gt, pred, num_classes, ignore_index255) tp np.diag(cm); fn cm.sum(1) - tp; fp cm.sum(0) - tp records.append({name: name, tp: tp, fn: fn, fp: fp}) # 找出目标类召回最差的 10 张图 target 4 ranked sorted(records, keylambda r: r[tp][target] / max(r[tp][target] r[fn][target], 1))有了这份账本骑行者 IoU 0.06 就不再是一个抽象数字而是十张具体图片点开看往往是逆光、遮挡、或者标注把骑行者并进了行人。这一步花的时间比盲目调三轮学习率省得多。另一个实用技巧是逐类偏置校准。在验证集上缓存 logits为每一类搜一个加性偏置 b_c重跑 argmax 看 mIoU 变化# 贪心坐标上升每次只调一类的偏置保留让 mIoU 上升的步长 bias np.zeros(num_classes) for _ in range(3): for c in range(num_classes): best_delta, best_score 0.0, current_miou(logits, gt, bias) for d in (-0.5, -0.25, 0.25, 0.5): bias[c] d s current_miou(logits, gt, bias) if s best_score: best_delta, best_score d, s bias[c] - d bias[c] best_delta这类后处理对小类通常有效因为模型在交叉熵下会天然偏向大类加一点负偏置就能把被压制的类找回来。但必须用独立的一批数据做校准在测试集上调参数再报测试集指标等于自己骗自己而且偏置一旦偏离太多误检会迅速反噬mIoU 涨 0.5 个点、精确率掉 10 个点是常事得盯着逐类的 precision 一起看。我自己踩过最贵的一次坑是在一个八类的地物分割任务上花了整整两周调网络结构最后发现验证集的标签在切片时做过一次重映射而评估脚本还用了旧的类别表有两个类的指标从头到尾都是错的。从那以后我养成了一个习惯任何一次实验开始前先花二十分钟打印混淆矩阵的行和、列和、空类数量、ignore 像素占比这四项和上一版逐项对一遍。这四行日志不产出任何指标却能挡掉大部分会让你怀疑模型、怀疑人生的假故障。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑