简介这份资源聚焦语义分割任务中的精度评估环节面向正在使用 PyTorch 训练分割模型、需要量化各类别表现的学生与算法工程师。它解决的是「模型整体指标尚可、却难以定位具体类别分割效果」的问题通过脚本演示如何从预测结果出发逐类计算 mIoU并汇总整个测试集的平均交并比适合已具备基础分割训练经验、希望补齐评估环节的读者。压缩包共 2 个文件均为 py 脚本整体约 4KB其中一个负责生成 8 位预测图另一个负责与 mask 逐类比对并输出各类别 mIoU 与测试集整体 mIoU两个脚本在 PyCharm 中直接运行即可。目前已有 7160 人学习下载说明该评估思路在分割实践中需求较高。读者可借此掌握逐类精度统计的完整流程理解预测图与标注图的对应关系并把这套计算逻辑迁移到自己的数据集与模型上用于分析类别不均衡、定位薄弱类别为后续调参与优化提供量化依据。1. 语义分割中各类别mIoU计算为什么你的模型明明很准却输在了指标上跑完一个语义分割训练脚本日志里val_mIoU涨到 0.78看着挺美。可一提交评测、一放到业务场景里小目标类别几乎全糊路面裂缝漏检、远处行人被吞、细长杆状物断成几截。问题往往不在网络结构而在你根本没认真看过语义分割中各类别mIoU计算这件事——你只盯着那个被平均掉的标量却不知道每个类别的 IoU 到底是多少、是被谁拉低的、混淆矩阵里哪两个类在互相打架。这篇笔记就围绕「各类别 mIoU」这一件事讲透混淆矩阵怎么攒、每个类的 IoU 怎么算、ignore_index和背景类怎么处理、多卡与累加时为什么会算错、以及怎么把逐类指标做成能直接定位问题的可视化。适合已经能跑通分割训练、但指标对不上、排错靠猜的从业者。下面所有代码都是可复现的最小实现不依赖某个具体框架的封装你把它贴进自己的验证循环就能用。2. 从混淆矩阵到逐类 IoU先把「谁被算进去了」讲清楚2.1 为什么不能直接对 batch 求平均很多人图省事在验证循环里对每个 batch 算一次 IoU最后取平均。这是语义分割中各类别mIoU计算最经典的翻车点。原因有两个一是每个 batch 的类别分布不同某个 batch 里可能压根没有「杆状物」这个类它的 IoU 就是 0 或者被跳过直接平均会让稀有类被系统性低估或高估二是 batch 平均等价于给每个 batch 相同权重而不同 batch 的像素数差异巨大大图小图一视同仁结果自然偏。正确做法是整个验证集只维护一个全局混淆矩阵所有 batch 的预测和标签都往这个矩阵里累加最后一次性从矩阵算出每个类的 IoU。混淆矩阵的维度是num_classes × num_classes第 i 行第 j 列表示「真实为 i、预测为 j」的像素数量。对角线是预测正确的行和是该类真实总数FNTP列和是该类预测总数FPTP。2.2 混淆矩阵的累加实现下面是一个不依赖框架的累加器核心就三行展平、过滤 ignore、bincount累加。import numpy as np class ConfusionMatrix: def __init__(self, num_classes, ignore_index255): self.num_classes num_classes self.ignore_index ignore_index # 全局矩阵dtype 用 int64 防止大验证集溢出 self.mat np.zeros((num_classes, num_classes), dtypenp.int64) def update(self, pred, target): pred: [H, W] 或 [N, H, W]int已 argmax 后的类别 id target: 同形状int真实标签 pred pred.flatten() target target.flatten() # 关键一步把 ignore 像素从两个数组里同时剔除 # 否则 ignore 会污染行和列导致 IoU 偏低 mask (target ! self.ignore_index) pred pred[mask] target target[mask] # 把 (target, pred) 编码成单个整数索引一次性累加 # 编码公式target * num_classes pred idx target * self.num_classes pred bincount np.bincount( idx, minlengthself.num_classes ** 2 ) self.mat bincount.reshape(self.num_classes, self.num_classes) def compute_iou(self): 返回 (per_class_iou, miou)per_class_iou 中无效类为 nan # 行和 真实为该类的像素数TP FN row_sum self.mat.sum(axis1) # 列和 预测为该类的像素数TP FP col_sum self.mat.sum(axis0) # 对角线 TP tp np.diag(self.mat) # IoU TP / (TP FP FN) TP / (行和 列和 - TP) denom row_sum col_sum - tp # 分母为 0 表示该类在验证集中既没出现也没被预测标记为 nan iou np.where(denom 0, tp / np.maximum(denom, 1), np.nan) miou np.nanmean(iou) return iou, miou逻辑说明update里先做 ignore 过滤这是最容易漏的一步。很多框架的CrossEntropyLoss(ignore_index255)在训练时忽略了这些像素但如果你在验证时忘了同步过滤混淆矩阵的行和列会被 255 这个「伪类别」撑大所有类的 IoU 都会被稀释。bincount的编码技巧把二维的 (target, pred) 对压成一维索引比双重循环快一到两个数量级验证集上百万像素也能秒级完成。参数说明num_classes必须和模型输出通道数一致且不包含ignore 类ignore_index要和数据集标注、损失函数保持一致常见值是 255 或 -1用之前先确认清楚。dtype选int64是因为一张 1024×1024 的图就有百万像素验证集上千张时int32有溢出风险。2.3 逐类 IoU 的读法与常见误判拿到per_class_iou数组后别只看nanmean。先看哪些类是nan——nan意味着该类在验证集里没出现过或者被 ignore 全过滤了这种类不该计入 mIoU否则你是在给一个不存在的类打分。再看哪些类 IoU 异常低通常集中在两类一是小目标类像素占比不到 1%被背景类压制二是语义相近类比如「轿车」和「卡车」、「人行道」和「路面」混淆矩阵里能看到明显的非对角高值。一个实用习惯把per_class_iou和每个类的像素占比并排打印。如果某个类像素占比 0.3%、IoU 0.2那它拉低 mIoU 是正常的你要做的是采样加权或损失重加权而不是怀疑代码。如果某个类像素占比 15%、IoU 却只有 0.3那才是真有问题去查标注质量或类别定义。3. 多卡、累加与 ignore把指标算对的三道坎3.1 多卡验证时混淆矩阵怎么合并单卡跑通不代表多卡对。DDP 验证时每张卡各自维护一个混淆矩阵如果只在 rank 0 上算指标你拿到的是「主卡看到的那部分数据」的 IoU不是全局的。正确做法是每个 rank 都 update然后在算指标前做一次跨卡求和。import torch import torch.distributed as dist def sync_confusion_matrix(mat, device): 把各 rank 的混淆矩阵求和到所有 rank tensor torch.from_numpy(mat).to(device) # 确保是连续内存all_reduce 对非连续张量行为不确定 tensor tensor.contiguous() dist.all_reduce(tensor, opdist.ReduceOp.SUM) return tensor.cpu().numpy()逻辑说明all_reduce默认是 SUM正好对应混淆矩阵的累加语义。注意要在compute_iou之前调用且所有 rank 都要参与否则会死锁。如果验证集是DistributedSampler切分的每个 rank 看到的是不重叠子集求和后就是全集如果用了padding导致重复采样那要先在 sampler 层去重否则指标会偏。参数说明device传当前 rank 的 GPUcontiguous()不能省某些后端对非连续张量的all_reduce会报错或结果错误。合并后只在 rank 0 上算 IoU 并打印即可避免日志重复。3.2 ignore_index 与背景类的边界处理ignore_index和「背景类」是两个概念混在一起是高频错误。背景类比如类别 id 0是一个真实存在的类它参与 IoU 计算ignore_index是「不参与任何计算」的像素比如标注时的边界模糊区域、未标注区域。如果你把背景类也设成 ignore那 mIoU 会虚高因为模型把背景预测错也不扣分。判断标准很简单这个类在业务上需不需要被正确分割需要就保留在num_classes里正常算不需要比如「其他」兜底类才考虑 ignore。另外注意有些数据集的标注里ignore_index和某个真实类 id 撞了比如都用 255这时候要么改标注要么在update里显式排除别指望框架帮你区分。3.3 一个可直接嵌入验证循环的完整片段把前面的东西串起来下面是一个最小可用的验证循环骨架假设你已经有了model、val_loader和num_classes。def validate(model, val_loader, num_classes, device, ignore_index255): model.eval() cm ConfusionMatrix(num_classes, ignore_index) with torch.no_grad(): for imgs, targets in val_loader: imgs imgs.to(device) logits model(imgs) # [N, C, H, W] preds logits.argmax(dim1) # [N, H, W] preds preds.cpu().numpy() targets targets.numpy() # 逐样本 update避免不同样本 ignore 数量不同导致错位 for p, t in zip(preds, targets): cm.update(p, t) # 多卡时在这里 all_reduce if dist.is_initialized(): cm.mat sync_confusion_matrix(cm.mat, device) iou, miou cm.compute_iou() return iou, miou, cm.mat逻辑说明argmax(dim1)得到每个像素的预测类别注意是在通道维上取别搞错维度。逐样本update而不是整个 batch 一起展平是因为不同样本的 ignore 像素数量不同逐样本处理逻辑更清晰也方便你中途打印某张图的混淆情况。model.eval()和torch.no_grad()都要有前者影响 BN 和 Dropout后者省显存。参数说明logits不要先做 softmaxargmax对单调变换不敏感省一步计算如果模型输出是[N, H, W, C]某些 TF 迁移过来的实现要先permute到[N, C, H, W]再 argmax这个顺序错误会让指标看起来「随机」。4. 避坑与排查逐类 IoU 对不上时先查这五处4.1 现象mIoU 比论文/基线低一大截逐类看全都偏低原因最常见的是验证时没做和训练一致的预处理。训练时 resize 到 512×512、归一化用 ImageNet 均值方差验证时如果直接喂原图或忘了归一化模型输出会整体偏移所有类 IoU 一起掉。另一个可能是ignore_index没过滤255 这个伪类吃掉了大量行和列。解决把验证的 transform 和训练的 val transform 逐行对比确认 resize、crop、normalize 完全一致在update里打印一次过滤前后的像素总数如果过滤掉的比例异常高比如超过 20%去查标注里 ignore 的占比是否合理。4.2 现象某个类 IoU 恒为 0但肉眼看着预测得还行原因这个类的 id 在num_classes范围外或者被ignore_index误伤。比如数据集有 20 个类id 是 1~20你设了num_classes20那 id 20 的类就被挤出去了或者标注里这个类用了 255和 ignore 撞了。解决打印target的唯一值集合和num_classes、ignore_index对照。唯一值里出现的最大 id 必须小于num_classes且不等于ignore_index。这一步花三十秒能省几小时排查。4.3 现象单卡指标正常多卡一开就掉原因混淆矩阵没做跨卡同步或者同步时机不对在compute_iou之后才 reduce。也可能是DistributedSampler的set_epoch没调导致每个 epoch 的切分不一致验证集有重复或遗漏。解决确认all_reduce在compute_iou之前验证时sampler.set_epoch(epoch)要调如果用了drop_last验证集不要开否则最后一批样本被丢指标会偏。4.4 现象逐类 IoU 加起来除以类数和框架报的 mIoU 不一致原因框架的 mIoU 可能用了像素加权而不是类平均或者把nan类算成了 0。类平均macro和像素加权micro在小目标类上差异巨大前者更敏感后者被大类主导。解决明确你要的是哪种。学术评测通常报 macro mIoU业务上如果关心整体像素准确率可以看 micro。在compute_iou里用np.nanmean而不是np.mean后者会把nan当 0 拉低均值。4.5 现象训练时指标涨验证时逐类 IoU 抖动大原因验证集太小某些稀有类在部分 epoch 里样本数为个位数IoU 方差极大。或者验证时开了数据增强比如随机翻转每次验证看到的样本不同。解决验证集至少保证每个类有几百个像素验证时关掉随机增强只保留确定性的 resize 和 normalize。如果数据实在少用 K 折交叉验证别用单次划分的抖动去判断模型好坏。5. 把逐类 IoU 用起来从指标到定位问题的进阶技巧算对指标只是第一步真正省时间的是用逐类 IoU 反推模型错在哪。我一般会在验证结束后做两件事一是把混淆矩阵归一化成行归一化每行除以行和这样对角线就是召回率非对角就是「真实类 i 被误判成 j 的比例」一眼能看出哪两个类在互相混淆二是把 IoU 最低的三个类对应的验证图挑出来可视化预测和标签的差异图通常看三五张就能定位是标注问题还是模型能力问题。下面这个片段把混淆矩阵转成可读的混淆对并按误判像素数排序直接告诉你「最该先修哪一对」。def top_confusions(mat, class_names, topk5): 找出误判像素最多的类别对 num_classes mat.shape[0] pairs [] for i in range(num_classes): for j in range(num_classes): if i ! j and mat[i, j] 0: pairs.append((mat[i, j], class_names[i], class_names[j])) # 按误判像素数降序 pairs.sort(reverseTrue, keylambda x: x[0]) for cnt, true_name, pred_name in pairs[:topk]: print(f真实[{true_name}] 被误判为 [{pred_name}]{cnt} 像素) # 用法class_names 是长度等于 num_classes 的列表 # top_confusions(cm.mat, class_names, topk5)逻辑说明行归一化后的非对角值乘以行和就是误判像素数这个数比比例更有业务意义——比例高但总量小的混淆对不值得优先修总量大的才是。class_names要和num_classes严格对齐顺序错了会张冠李戴。参数说明topk按你的类数调类少就全打类多取前 5 到 10。如果某一对误判量远超其他优先查这两个类的标注边界是否一致比如「路面」和「人行道」的交界处标注员经常画混这种问题改标注比改模型划算得多。还有一个我踩过的坑别用训练集的逐类 IoU 判断模型好坏。训练集上模型见过所有样本稀有类也能记住IoU 虚高没有参考价值。验证集和测试集的逐类 IoU 才是决策依据而且测试集最好只跑一次反复调参看测试集指标等于变相过拟合。最后一个习惯把每次实验的逐类 IoU 存成 CSV列是类别行是实验编号。跑了几十组之后你会发现自己能一眼看出「这次改动到底帮了哪个类、伤了哪个类」比只看一个 mIoU 数字有用得多。指标不是用来汇报的是用来定位问题的——这句话我用了很久才真正理解希望帮到你。本文还有配套的精品资源点击获取