简介:Pascal-5i 数据集配套资料面向小样本学习研究者与入门者,围绕 5-way 1-shot/5-shot 场景提供标准化实验入口,帮助理解基础类别与新类别的划分、采样策略及模型泛化能力评估。压缩包共 6 个文件,以 Python 数据读取脚本、Jupyter 示例笔记、Markdown 说明、依赖清单和许可证为主,整体约 151KB,便于快速搭建可复现的小样本实验环境。目前已有 1445 人学习。读取脚本可用于加载并组织 PASCAL VOC 图像,示例笔记展示任务构造与调用流程,说明文档补充数据来源、目录约定和使用边界,依赖清单则降低环境配置阻力。读者可据此复现原型学习、元学习等方法的训练与评测流程,并关注 F1 分数、one-vs-rest 混淆矩阵等更适合小样本场景的指标,从而加深对少样本分类任务的理解。
1. PASCAL-5i 到底在测什么:从 20 类 VOC 里切出的四折小样本考场
第一次看到 PASCAL-5i 这个数据集名,大部分人的困惑是“下完 PASCAL VOC 2012 之后还得再做什么”,而“它是什么”反倒没那么难回答。它其实是一套评测协议:从 VOC 的 20 个前景类里按字母序切四刀,每折留 5 类做测试、其余 15 类做训练,图像沿用 VOC 与 SBD 的原始划分。所以它不能解压即用,需要自己把类别、图像列表、二值掩码和 episode 采样器拼起来。这套流程主要服务于少样本语义分割:给定 1 张或 5 张带目标类掩码的 support 图,去 query 图上把同一类抠出来。想做小样本分割、域适应、开集识别的从业者都绕不开它,因为它是目前最省算力、最容易横向对比的那把尺子。
2. 四折怎么切、图从哪来:把 PASCAL-5i 的目录自己搭出来
2.1 20 类切成 4 折的规则,以及 train/test 为什么不能重叠
PASCAL-5i 的 5i 指的是每折测试集 5 个类,i 就是 fold 编号。切法固定:按 VOC 的类别字母序排好后连续五类一组,第 0 折是 aeroplane / bicycle / bird / boat / bottle,第 1 折是 bus / car / cat / chair / cow,第 2 折是 diningtable / dog / horse / motorbike / person,第 3 折是 pottedplant / sheep / sofa / train / tvmonitor。
这个顺序并非随意决定。VOC 的类别索引本身就按字母序编号 0 到 19,所以四折等价于把索引区间每 5 个切一段。很多人手写类别表时按“常见程度”重排,结果 fold 对不上,跟别人比数字时就出现了莫名其妙的偏差。
真正的约束是类别不重叠:某一折的 5 个测试类,绝对不能出现在该折的训练图像里。注意这里说的是“类”不出现,而不是“图”不出现。一张图里可能同时有 person 和 dog,如果 person 是第 2 折的测试类,而这张图被划进了第 2 折的训练集,那么这张图里的 dog 可以用来训练,但训练时必须把 person 的像素从标签里剔掉或置为忽略,否则就构成类别泄漏。血泪经验是:泄漏不一定让数字虚高得离谱,但会让你的模型在测试类上“莫名地好”,而这个好无法复现。
标准做法是把训练图像的标签做目标类二值化:当前 episode 的目标类是哪个,标签里只保留这一类为前景(1),背景和其他所有前景类都置 0。VOC 标注里还有一圈值为 255 的边界忽略区,读进来必须单独处理,不能让它参与二值化。
下面是折划分的定义,直接照抄即可:
# pascal5i_splits.py # VOC 类别按字母序编号 0-19,PASCAL-5i 的四折就是连续 5 个索引一段 VOC_CLASSES = [ 'aeroplane', 'bicycle', 'bird', 'boat', 'bottle', 'bus', 'car', 'cat', 'chair', 'cow', 'diningtable', 'dog', 'horse', 'motorbike', 'person', 'pottedplant', 'sheep', 'sofa', 'train', 'tvmonitor', ] SPLITS = [ [0, 1, 2, 3, 4], # fold 0 [5, 6, 7, 8, 9], # fold 1 [10, 11, 12, 13, 14], # fold 2 [15, 16, 17, 18, 19], # fold 3 ] def get_fold(fold): assert 0 <= fold < 4 test_ids = SPLITS[fold] train_ids = [i for i in range(20) if i not in test_ids] return train_ids, test_ids if __name__ == '__main__': for f in range(4): tr, te = get_fold(f) print(f'fold{f} train={[VOC_CLASSES[i] for i in tr]}') print(f'fold{f} test ={[VOC_CLASSES[i] for i in te]}')这段代码里train_ids的求法是“全集减测试集”,而不是硬编码三个列表;这样一改测试类,训练类会自动跟着变,减少手抄出错的概率。SPLITS里每个子列表必须严格连续且长度都是 5,这是 PASCAL-5i 的定义,不是可调参数。真要改,那就是另一套协议了,比如把类别打乱做更严格的泛化测试,那时就该换名字重新记录结果。
2.2 VOC2012 与 SBD 两套标注的差别,为什么训练侧要用增强版
VOC2012 的分割标注只有约 1.5k 张训练图,且标注比较粗,物体边缘往往贴不紧。SBD(语义边界数据集)针对同一批图提供了更精细的实例级标注,训练划分规模大约是 VOC2012 训练集的六倍,额外补了大量带标注的图。少样本分割的训练集本来就小,训练图像数量直接决定模型能不能学到像样的特征,所以业界惯例是训练侧用 SBD 增强版,测试侧仍用 VOC2012 的验证集图像。
这个惯例带来一个复现陷阱:只用 VOC2012 原版标注训练,1-shot 的 mIoU 通常会比论文低好几个点,而且低得很均匀,四个折都低。此时先检查这处差异,再去调超参,可以省下很多无效调试。
SBD 的标注以 MATLAB 的.mat格式发布,分为cls和inst两个目录,前者是语义级类别图,后者是实例级图。做 PASCAL-5i 的二值分割只需要cls,里面数值是 0 到 20,0 是背景,1 到 20 对应 VOC 的类别编号(注意 SBD 的编号比 VOC 原生的 0 到 20 有细微差别,转换时别直接假设一一对应,稳妥做法是先按类名建映射再查表)。
VOC2012 的分割标注是调色板 PNG,直接用 PIL 读会得到索引值而不是类别值,需要按调色板反查。这个坑极其常见:读出来一看全是 0 到 255 的杂数,其实是调色板索引。用np.array(Image.open(p))读进来再按PIL.Image.open(p).getpalette()对应的映射转,或者干脆用专门的数据读取工具,能绕开一堆玄学问题。
2.3 一份能跑通的构建脚本:类别重映射与掩码写入文件
整个构建过程分三步:列出可用图像、给每张图标注“包含哪些类”、按折生成训练/测试列表并转换掩码。第三步最关键,写出来的掩码要么是全类别的语义图(评估时再二值化),要么是每类一张的二值图(训练时直接取)。前者省磁盘,后者省时间,我一般选前者,因为 20 类全量转换也就几 GB 量级,而每类一张会膨胀 20 倍。
import os, glob import numpy as np import scipy.io as sio from PIL import Image def sbd_cls_to_png(mat_path, out_path): """把 SBD 的 cls/*.mat 转成单通道 PNG,像素值 = 0..20 的类别 id""" gt = sio.loadmat(mat_path)['GTcls'][0][0]['Segmentation'] seg = gt.astype(np.uint8) Image.fromarray(seg, mode='L').save(out_path) return seg def build_index(mask_dir, class_names): """扫描所有掩码,记录每张图包含哪些类别,供 episode 采样用""" records = [] for p in sorted(glob.glob(os.path.join(mask_dir, '*.png'))): seg = np.array(Image.open(p)) present = sorted(int(v) for v in np.unique(seg) if 1 <= v <= 20) if not present: continue # 纯背景图对训练无意义,直接丢掉 records.append({'mask': p, 'classes': present}) return records def pick_by_fold(records, test_ids, train_ids): """测试集只保留含测试类的图,训练集只保留含训练类的图""" test_set = [r for r in records if set(r['classes']) & set(test_ids)] train_set = [r for r in records if set(r['classes']) & set(train_ids)] return train_set, test_set def binarize(seg, target_id, ignore=255): """目标类 -> 1,其余前景与背景 -> 0,255 保留为忽略""" out = np.zeros_like(seg, dtype=np.uint8) out[seg == target_id] = 1 out[seg == ignore] = ignore return outsbd_cls_to_png里用的是GTcls的Segmentation字段,不是inst;选错字段会得到实例编号,做二值分割时会发现同一类被拆成了互不相连的几块,IoU 直接掉下去。build_index里1 <= v <= 20这个过滤条件把背景 0 排除了,因为“只有背景”的图在训练时没有正样本,白占一轮迭代。pick_by_fold用的是交集判断,一张图只要含测试类就进测试集,哪怕它同时含训练类;这符合 VOC 的原始划分逻辑,但你要清楚这意味着测试图里可能存在训练类的前景,评估时这些像素应算作负样本。binarize把 255 单独保留,这是后面算损失和算 IoU 时都要用到的一个约定,破坏这个约定就会踩到第 5 章讲的坑。
3. episode 采样:1-shot 与 5-shot 的 support 与 query 到底怎么配
3.1 一个 episode 的硬约束,以及 query 里没目标类时怎么办
episode 是少样本分割的基本单位:一个 support 集合(K 张图 + 目标类的二值掩码)加一个 query 集合(若干张图 + 待预测的掩码)。采样时必须满足三条硬约束。
第一,support 图必须确实包含目标类,而且该类的像素面积不能太小。面积过小的样本(比如只占几十个像素的 tvmonitor)会让 support 掩码几乎全黑,模型提取到的原型接近噪声。我的习惯是加一个最小面积阈值,比如目标类像素占全图 0.1% 以下就重采,这个阈值不是论文里的标准,但它能显著降低训练初期的梯度抖动。
第二,query 和 support 必须图像不重叠。同一个 fold 里图像数量有限,随机采很容易撞上。撞上之后模型等于在做记忆任务,训练 loss 掉得飞快,验证集纹丝不动。
第三,query 里没出现目标类的情况要明确处理。有两种协议:一种是只从含目标类的图里采 query(保证有正样本),另一种是从整个测试集里采、允许负样本。前者算出来的 IoU 偏高,后者更贴近真实部署。多数论文用的是前者,因为它对 5 个测试类更公平。如果你拿自己的方案跟论文比,先确认这一条一致,否则差 2 到 3 个点是常事。
5-shot 的 support 采样还有个细节:5 张图应该是随机独立采的,而不是从固定的一小撮图里挑最干净的 5 张。后者在训练时看起来更稳,实际是把难点藏了起来,报告的数字虚高。我一般会生成多组 support(比如 5 组不同种子),每组跑一遍评估取平均,这样报出来的数才经得起别人换种子复现。
3.2 固定 episode 列表的生成脚本与缓存复用
episode 必须能复现。做法是训练前一次性把 episode 列表生成好、以 JSON 写入文件,训练和评估都从这个文件里读,而不是每次__getitem__时现场随机。
import json, random def gen_episodes(records, test_ids, k_shot, n_episode, seed, min_ratio=0.001): rng = random.Random(seed) # 按类别分桶,桶里存 (mask_path, 前景像素占比) buckets = {c: [] for c in test_ids} for r in records: seg = np.array(Image.open(r['mask'])) for c in test_ids: area = int((seg == c).sum()) if area > 0: buckets[c].append((r['mask'], area / seg.size)) episodes = [] for _ in range(n_episode): cls = rng.choice(test_ids) pool = [p for p, ratio in buckets[cls] if ratio >= min_ratio] if len(pool) < k_shot + 1: continue picked = rng.sample(pool, k_shot + 1) # 前 k_shot 张做 support,最后 1 张做 query episodes.append({ 'class_id': cls, 'support': picked[:k_shot], 'query': picked[k_shot:], }) return episodes if __name__ == '__main__': for fold in range(4): with open(f'episodes_fold{fold}_1shot.json', 'w') as f: json.dump(gen_episodes(records, SPLITS[fold], 1, 1000, seed=fold), f)min_ratio=0.001是前面提到的最小面积门槛,按图像总像素归一化后比较,与图像尺寸无关,比写死像素数更稳。rng.sample一次抽k_shot + 1张保证了 support 与 query 天然不重叠,比先抽 support 再从剩余里抽 query 更不容易写错。seed=fold让每折的 episode 序列固定但彼此不同,避免四折共用同一批 support 导致结果相关性过高。文件名里带1shot是为了区分不同 shot 设置,5-shot 换一个k_shot重新生成就行,但种子要一致,这样 1-shot 和 5-shot 的对比才是同分布下的对比。
写入文件的另一个好处是评估阶段不用再重新采样。推理时的随机性来源只剩模型本身(dropout、数据增强),排查问题时能少一条线索。
3.3 小样本下的增强取舍:能开什么、不能开什么
少样本分割的训练图本来就只有一两千张量级,增强几乎是必需环节。但增强做过头会把 support 和 query 的分布拉歪,尤其是那些会改变类别语义的操作。
可以放心开的有:随机缩放裁剪(缩放到 0.8 到 1.25 倍再裁回固定尺寸)、水平翻转、颜色抖动(亮度/对比度/饱和度小幅扰动)。这几种在多数实现里都是标配。
要谨慎的有:随机旋转。旋转本身没问题,但 support 掩码必须跟着同一套几何变换走,否则掩码和图像对不上。我见过最典型的翻车是只对图像做了旋转、掩码用的还是原图裁出来的,训练 loss 看着正常,因为模型在学“平均意义上的前景”,但 IoU 一直上不去。
绝对不能开的有:CutMix、Mosaic 这类跨图拼接。少样本分割的 support 掩码是逐像素的二值图,拼接后掩码的语义边界就乱了。另外,随机擦除在 support 图上也不合适,它可能把目标类的唯一一块前景擦掉,support 就退化成纯背景。
增强的强度也有讲究。颜色抖动幅度过大(比如亮度 ±0.5)会让 support 和 query 来自同一条街的两张图看起来像两个季节拍的,原型匹配的余弦相似度掉得很厉害。我的经验是把亮度/对比度控制在 ±0.2 以内、饱和度 ±0.3 以内,这个量级既能让模型对光照变化鲁棒,又不至于破坏类内一致性。
4. 训练侧最小闭环:从 dataloader 张量到二值分割头
4.1 support/query 张量的形状、归一化与掩码对齐
dataloader 输出的形状是整个实现里最容易各处不一致的地方。我统一约定:图像张量是[B, 3, H, W],掩码张量是[B, 1, H, W](float32 或 long,取值 0/1/255)。support 的形状则多一维[B, K, 3, H, W]和[B, K, 1, H, W]。
归一化用 ImageNet 的均值和方差,这是从预训练主干继承来的约定。如果你换成了自己训的主干,那就按你训主干时的统计量来;两边不一致是典型的“训练正常、推理崩盘”来源。
尺寸这一项要特别说清楚:训练时统一 resize 到固定尺寸(常见是 473×473 或 512×512,取能被主干下采样倍数整除的值),但评估时的掩码插值方式有两种流派。一种是预测结果上采样回原图尺寸再跟原图掩码算 IoU,另一种是把掩码下采样到预测尺寸再算。这两者差别能有 1 到 2 个点,因为小目标的边缘在插值时会被吃掉或生成出来。跟论文比之前先确认它用的是哪一种。
import torch from torch.utils.data import Dataset from PIL import Image import numpy as np class EpisodeDataset(Dataset): def __init__(self, episodes, img_root, mask_root, size=473, ignore=255): self.episodes = episodes self.img_root, self.mask_root = img_root, mask_root self.size, self.ignore = size, ignore def _load_img(self, mask_path): name = os.path.splitext(os.path.basename(mask_path))[0] img = Image.open(os.path.join(self.img_root, name + '.jpg')).convert('RGB') img = img.resize((self.size, self.size), Image.BILINEAR) arr = np.asarray(img, dtype=np.float32) / 255.0 arr = (arr - np.array([0.485, 0.456, 0.406])) / np.array([0.229, 0.224, 0.225]) return torch.from_numpy(arr).permute(2, 0, 1) def _load_mask(self, mask_path, cls_id): seg = np.array(Image.open(mask_path)) seg = np.array(Image.fromarray(seg).resize((self.size, self.size), Image.NEAREST)) m = np.full(seg.shape, 0, dtype=np.int64) m[seg == cls_id] = 1 m[seg == self.ignore] = self.ignore return torch.from_numpy(m).unsqueeze(0) def __getitem__(self, idx): ep = self.episodes[idx] sup_x = torch.stack([self._load_img(p) for p in ep['support']]) sup_y = torch.stack([self._load_mask(p, ep['class_id']) for p in ep['support']]) qry_x = torch.stack([self._load_img(p) for p in ep['query']]) qry_y = torch.stack([self._load_mask(p, ep['class_id']) for p in ep['query']]) return sup_x, sup_y, qry_x, qry_y, ep['class_id']掩码 resize 必须用Image.NEAREST,用双线性会插出 0.5 这种不存在的类别值,二值化时全变成前景或全变成背景。m先全填 0 再单独赋值,等价于“除目标类外全是背景”,这是 PASCAL-5i 二值协议的标准做法。ignore在 resize 之后仍然保持 255,是因为最近邻不会产生新值。图像侧先 resize 再归一化,顺序反了同样会出问题,因为归一化会造出负值,PIL 处理不了。
4.2 损失函数怎么设:忽略区、类别不平衡与背景类
二值分割的损失看似简单,实操里有两处必须调。
第一处是忽略区。VOC 的 255 边界像素在训练时不能算进 loss,做法是算完逐像素 loss 后乘一个 mask,把 255 的位置权重置 0,再除以有效像素数。如果直接mean(),忽略区会被当成背景参与梯度,模型会倾向于把边界附近全预测成背景,边缘质量明显变差。
第二处是类别不平衡。少样本场景下目标类往往只占图的一小部分,纯交叉熵会让模型一路往“全预测背景”的方向滑。常见做法是交叉熵和 Dice 各取一半。Dice 对前景比例不敏感,能拉住召回率;交叉熵稳定初期梯度。两者的权重按 1:1 起步,如果发现召回一直上不去,把 Dice 提到 1.5;如果发现过分割严重(预测的前景面积远大于真值),把 Dice 降到 0.5 并提高交叉熵。
import torch.nn.functional as F def seg_loss(logits, target, ignore=255, dice_w=1.0): valid = (target != ignore).float() t = target.clone().float() t[target == ignore] = 0 ce = F.binary_cross_entropy_with_logits(logits, t, reduction='none') ce = (ce * valid).sum() / valid.sum().clamp(min=1.0) prob = torch.sigmoid(logits) * valid inter = (prob * t * valid).sum(dim=(2, 3)) union = (prob * valid).sum(dim=(2, 3)) + (t * valid).sum(dim=(2, 3)) dice = 1 - (2 * inter + 1.0) / (union + 1.0) return ce + dice_w * dice.mean()valid在 Dice 里既乘在预测上也乘在真值上,这两处都要乘,只乘一边会让分母虚高、Dice 恒偏小。+1.0的平滑项不能省,某些 batch 里可能一个前景像素都没有,没有平滑项会直接除零。reduction='none'是必须的,不然没法按valid加权。
4.3 推理阶段的两条常见路线:prototype 匹配与解码器融合
训练完,推理阶段有两条主流路线,选哪条取决于你的目标。
第一条是原型匹配。把 support 图过一遍主干得到特征图,用 support 掩码做掩码平均池化,得到一个通道维的向量(原型)。query 特征图每个位置跟这个原型算余弦相似度,超过阈值的判为前景。这条路线的优点是快、省显存、可解释,缺点是当目标类的外观差异大时(比如不同姿态的 dog),单一原型表达不了。
第二条是解码器融合。把 support 的特征和掩码在通道维拼起来,通过一个轻量解码器预测 query 的掩码。这条路线的上限更高,但训练更不稳定,且对 support 的质量更敏感。
实操里我一般先用原型路线把整条数据管线跑通、拿到一个基线数字,再上解码器做对比。原因是原型路线的参数量少、调参空间小,一旦数字不对,八成是数据侧的问题;直接上解码器的话,你分不清是数据错了还是模型没训好。
推理时还有个容易忽略的点:阈值。原型路线算出的相似度经过 sigmoid 后,0.5 不一定是最优阈值。有些实现在验证集上扫一遍 0.3 到 0.7 找最佳阈值再报告测试结果,这在学术评测里不太规范(等于用测试集调参),但在工程落地里是标准动作。如果要跟论文比,就用固定 0.5。
5. 对不上论文数字时的排查顺序:PASCAL-5i 上五个最贵的坑
5.1 现象:四个折普遍低 3 到 5 个点,且折间差异正常
这是最常见的一种。折间差异正常说明类别重映射没问题,普遍偏低说明训练数据量不够。
原因基本锁定在训练侧用了 VOC2012 原版标注而不是 SBD 增强版。少样本分割的模型容量通常不大,训练图像数量对最终性能的影响非常直接。VOC 原版大约 1.5k 张,SBD 增强后是它的数倍,差距就体现在这几个点上。
解决方式是把训练侧的掩码来源切成 SBD,测试侧仍然用 VOC 验证集。切换之后 mask 的编号体系可能不一致,需要按类名重建映射表。切完之后如果还差,再去看是不是测试侧误用了 SBD 的验证划分,那样类别分布会偏,数字可能虚高也可能虚低。
5.2 现象:某一折特别低,其余三折都正常
折间差异明显异常,指向类别映射层面的错误。
原因通常是某一折的类别编号写错了。比如把 pottedplant 和 sheep 的索引对调,那一折的测试类就全错了,模型在测一个它根本没学过的类。另一种情况是类别表里多写或少写了一个类,导致后面所有索引整体偏移一位。
解决办法是打印每折的类别名列表,跟类别定义逐条核。核对时别只看编号,把类名一起打出来看,编号对但类名错的情况也很多(比如两种拼写都被人用过)。核完再检查掩码里的实际像素值分布,每种测试类至少应该在若干张图里出现,如果某一类在全数据集里都找不到,说明转换那一步就已经丢类了。
5.3 现象:mIoU 出现 NaN,或者某一类的 IoU 恒为 0
NaN 的来源几乎都是除零。
原因之一是一批 episode 里 query 图压根没有目标类的前景像素,交集和并集都是 0,0/0就炸了。前面提过,采样时如果允许负样本 query,这种情况一定会出现。
原因之二是评估分辨率不一致。预测上采样回原图后,某个小目标的预测面积被插值放大,而真值本来就只有几十个像素,算出来 IoU 很低但不会 NaN;反过来把真值下采样到预测尺寸,小目标可能直接被插没,这时并集为 0。
解决办法有两层:代码层面在所有除法上加保护,并集为 0 时返回 NaN 而不是 0,最后用nanmean聚合;协议层面把 query 采样限制在含目标类的图上,与论文口径一致。另外在评估日志里把每个类的有效样本数打出来,某一类样本数为 0 时立刻能看出来。
5.4 现象:同一份权重跑两次评估,数字差 1 到 2 个点
权重没动,评估结果在跳,只有一个解释:评估过程带随机性。
原因可能是 episode 现场采样而没读固定列表,也可能是数据增强在验证阶段没关(有些实现忘了切model.eval()或者 transform 里带了随机裁剪),还可能是 dropout 没关。
解决办法是把验证和测试的 transform 固定成“resize + 归一化”两步,不带任何随机操作;episode 从 JSON 读;评估前显式调用model.eval()并用torch.no_grad()包住。做完这三件事,同一份权重的两次评估结果应该逐位相同。如果还不同,那就是多卡或者非确定性算子的问题,把torch.use_deterministic_algorithms(True)打开,能定位到具体是哪一层。
这条看起来是小事,但它是所有消融实验可信度的地基。评估本身抖动 2 个点,你做的 1 个点提升就毫无意义,血泪经验。
5.5 现象:5-shot 相比 1-shot 只涨了一点点
5-shot 的正常提升幅度应该是可观的,如果几乎没涨,问题多半出在 support 的融合方式上。
原因是实现里虽然采了 5 张 support,但只是把它们各自过一遍网络再把 logits 平均,没有做特征层面的融合。这种做法等价于 5 个独立的 1-shot 模型做集成,提升有限。更有效的做法是在特征层就把 5 张 support 的前景特征拼在一起做池化,得到一个更稳健的原型,或者让解码器同时看到 5 张 support 的特征。
另一个可能是 5 张 support 图采得太相似(同一种场景、同一个尺度),信息冗余。生成 episode 时加一个约束,让 5 张图的来源尽量分散,能明显改善。改完这两处,5-shot 相对 1-shot 的提升通常会回到应有的量级。
6. 把 PASCAL-5i 当自己的验证台:四折循环与结果聚合
单折跑出来的数字只能说明“在这个类别组合上还行”,要说方法稳,得四折都跑。四折的类别组合差异很大,第 2 折有 person、dog、horse 这些大类,第 3 折是 pottedplant、sofa、train 这类长尾,平均下来才是完整的画像。
四折循环的写法没什么花哨,关键在于结果的组织方式。我习惯每折记录三样东西:5 个测试类各自的 IoU、该折的均值、以及本次运行的关键配置(shot 数、训练掩码来源、评估分辨率)。缺了第三样,过两周回来完全看不懂自己当时跑的是什么。
| 字段 | 示例 | 说明 |
|---|---|---|
| fold | 2 | 折编号 0 到 3 |
| shot | 1 | support 张数 |
| train_mask | sbd | voc 或 sbd,直接决定能否横向对比 |
| eval_size | origin | origin 表示上采样回原图算 IoU,fixed 表示统一尺寸 |
| per_class_iou | 62.1 / 58.4 / ... | 顺序必须与该折类别表一致 |
| miou | 59.7 | 该类均值,聚合时用 nanmean |
聚合时有个细节:四折平均不是简单的算术平均,如果某一折的某个类因为训练数据太少而算出 NaN,直接mean会把整折拉成 NaN。用np.nanmean逐类聚合,再对类取平均,得到的数字才跟论文口径对得上。另外,有些报告写的是“四折平均”,有些写的是“四折中最好的一折”,读别人数字时要看清是哪种,不然会以为自己复现不上去。
省时间的一个技巧是缓存转换后的掩码和特征。掩码转换是一次性的,转完存成.npy,后续每次训练直接 load,省掉 PIL 解码和最近邻 resize。如果主干是冻结的(原型路线里很常见),support 的特征也可以缓存,因为同一张 support 图在固定 episode 列表下每次算出来的特征完全一样。这两项加起来,一轮四折实验的耗时能压到原来的六成左右,做消融的时候差别很明显。
我自己踩得最深的一脚是:早期为了跑得快,把评估分辨率固定成 473×473 跟预测对齐,结果小目标类的 IoU 比论文低了快 8 个点,排查了两天才发现是协议不同。从那以后,我在任何数据集上的第一件事不是跑模型,而是先把评估脚本单测一遍:拿真值当预测喂进去,IoU 必须恰好是 100,不是 100 就是协议写错了。这个习惯帮我省掉的调试时间,比任何调参技巧都多。希望帮到你。
本文还有配套的精品资源,点击获取