☰
pascal-5i数据集小样本分割实战:fold划分、episodic采样与避坑指南
2026/10/20 6:50:36 网站建设 项目流程

简介:Pascal-5i 数据集面向小样本学习方向的研究者与算法工程师,基于经典 PASCAL VOC 的 20 个类别,通过采样策略划分基础类别与新类别,支持 5-way 1-shot 与 5-way 5-shot 场景,用于评估模型在极少样本下的泛化与适应能力。资源包共 6 个文件,压缩后约 151KB,包含 py 读取脚本、ipynb 示例笔记、md 说明文档、txt 依赖清单及 license 等,覆盖数据加载、实验演示与使用说明等环节,便于快速接入原型学习、元学习或知识迁移等方法的训练与评测流程。目前已有 1444 人学习下载。借助配套脚本与示例,读者可省去数据解析与划分的重复工作,直接复现小样本分类实验,并对照 F1 分数、one-vs-rest 混淆矩阵等评估方式分析模型表现,适合作为入门与对比实验的标准化起点。

1. pascal-5i数据集:为什么你的小样本分割模型总在5-way 1-shot上翻车

如果你正在做小样本语义分割,大概率绕不开 pascal-5i 数据集。它不是什么新数据集,而是把 PASCAL VOC 2012 重新切分后得到的一套标准小样本评测协议。很多人第一次跑 5-way 1-shot 时都会遇到同一个反直觉现象:训练集上 mIoU 冲到 60 多,换到测试的 novel 类别直接掉到 20 出头。问题往往不在模型结构,而在你根本没搞清楚 pascal-5i 的 fold 划分逻辑和 episodic 采样规则。这套数据集解决的核心问题,是让不同论文的小样本分割结果能在同一套类别划分和采样协议下比较。它适合两类人:一是刚入门小样本分割、需要一套可复现 baseline 的工程师;二是已经跑通但指标对不上、需要排查数据管线的人。下面从划分逻辑讲到落地采样,再到避坑和验证,全部按能直接抄作业的方式写。

2. pascal-5i 的 fold 划分与 episodic 采样:先把协议吃透再写模型

2.1 四个 fold 到底怎么切、为什么不能自己乱切

pascal-5i 的全称里那个 “5i” 指的是 5 个类别一组、共 4 个 fold 的交叉验证协议。PASCAL VOC 2012 分割任务原本有 20 个前景类加 1 个背景类,pascal-5i 把这 20 个类分成 4 组,每组 5 个类。每个 fold 里,15 个类作为 base(训练时可见),5 个类作为 novel(测试时才出现)。四个 fold 轮流让不同的 5 类当 novel,保证每个类都有机会被当作新类评测。

这里第一个坑就来了:很多人以为 base 和 novel 是随机切的,其实不是。常见的做法是固定划分,比如 fold-0 的 novel 类是 aeroplane、bicycle、bird、boat、bottle,fold-1 是 bus、car、cat、chair、cow,以此类推。你如果自己随机切,指标和论文完全没法比。我一般会直接把划分写死在配置文件里,避免每次跑实验时采样逻辑引入随机性。

# pascal_5i_split.py # 固定 pascal-5i 四个 fold 的类别划分,类别名对应 PASCAL VOC 2012 的 20 个前景类 PASCAL_5I_SPLITS = { "fold0": { "novel": ["aeroplane", "bicycle", "bird", "boat", "bottle"], "base": ["bus", "car", "cat", "chair", "cow", "diningtable", "dog", "horse", "motorbike", "person", "pottedplant", "sheep", "sofa", "train", "tvmonitor"] }, "fold1": { "novel": ["bus", "car", "cat", "chair", "cow"], "base": ["aeroplane", "bicycle", "bird", "boat", "bottle", "diningtable", "dog", "horse", "motorbike", "person", "pottedplant", "sheep", "sofa", "train", "tvmonitor"] }, "fold2": { "novel": ["diningtable", "dog", "horse", "motorbike", "person"], "base": ["aeroplane", "bicycle", "bird", "boat", "bottle", "bus", "car", "cat", "chair", "cow", "pottedplant", "sheep", "sofa", "train", "tvmonitor"] }, "fold3": { "novel": ["pottedplant", "sheep", "sofa", "train", "tvmonitor"], "base": ["aeroplane", "bicycle", "bird", "boat", "bottle", "bus", "car", "cat", "chair", "cow", "diningtable", "dog", "horse", "motorbike", "person"] } }

这段配置的关键在于:novel 和 base 的并集必须恰好是全部 20 个类,且两两不重叠。参数上你只需要改 fold 名来切换评测目标。逻辑说明:训练阶段只允许用 base 类的标注数据,novel 类的图像可以出现但不能用其分割标签;测试阶段才用 novel 类的 support 集和 query 集。如果你在训练时不小心把 novel 类的 mask 也喂进去了,指标会虚高,这就是典型的协议违规。

2.2 episodic 采样:5-way 1-shot 的一个 episode 长什么样

小样本分割的评测单位是 episode。一个 episode 包含一个 support set 和一个 query set。5-way 1-shot 的意思是:从当前 fold 的 novel 类里随机抽 5 个类,每个类给 1 张带标注的 support 图像,再给若干张 query 图像(通常每类若干张),要求模型根据 support 的分割结果去分割 query。

这里第二个坑是采样粒度。常见做法是每个 episode 的 5 个类不重复,support 图像从该类中随机选一张,query 图像从剩余图像中选。但有些实现会把 support 和 query 混在一起,导致同一张图既当 support 又当 query,指标直接失真。我一般会在 Dataset 类里显式维护一个已用图像集合,确保 support 和 query 不重叠。

import random from collections import defaultdict class EpisodicSampler: """按 pascal-5i 协议采样 episode,保证 support 与 query 不重叠""" def __init__(self, novel_classes, image_dict, n_way=5, n_shot=1, n_query=5): self.novel_classes = novel_classes # 当前 fold 的 5 个 novel 类 self.image_dict = image_dict # {class_name: [img_id, ...]} self.n_way = n_way self.n_shot = n_shot self.n_query = n_query def sample_episode(self): # 1. 从 novel 类中不放回抽 n_way 个类 selected = random.sample(self.novel_classes, self.n_way) support, query = [], [] for cls in selected: imgs = self.image_dict[cls][:] random.shuffle(imgs) # 2. 前 n_shot 张做 support,紧接着 n_query 张做 query if len(imgs) < self.n_shot + self.n_query: raise ValueError(f"{cls} 图像不足,无法构成 episode") support.append((cls, imgs[:self.n_shot])) query.append((cls, imgs[self.n_shot:self.n_shot + self.n_query])) return support, query

参数说明:n_way 固定为 5 对应 5-way;n_shot 设 1 就是 1-shot,设 5 就是 5-shot;n_query 常见取 5 或 10,取决于你的显存和评测稳定性。逻辑上,support 和 query 的切分必须在同一个类内完成,且不能跨类复用图像。如果你发现同一张图在两个 episode 里反复出现,检查一下 image_dict 是否被原地修改了。

2.3 训练时 base 类怎么用、novel 类怎么隔离

训练阶段的标准做法是:在 base 类上做 episodic 训练,或者做普通的语义分割预训练再元学习。无论哪种,novel 类的分割标签绝对不能进入梯度更新。常见做法是在 DataLoader 层面加一个过滤器,只保留 base 类的样本。

def build_base_loader(all_samples, base_classes, batch_size=4): """只保留 base 类的样本用于训练,novel 类样本直接丢弃""" base_samples = [s for s in all_samples if s["class_name"] in base_classes] # 注意:这里不能简单按类别过滤,还要保证每张图的 mask 只包含 base 类 # 如果一张图里同时有 base 和 novel 类,常见做法是把 novel 类像素置为 ignore_index return base_samples

这里有个容易忽略的点:PASCAL VOC 的一张图里可能同时出现 base 类和 novel 类。如果你只按图像级过滤,novel 类的像素仍然会参与 loss 计算。正确做法是在生成 mask 时,把 novel 类对应的像素值改成 255(ignore_index),让损失函数跳过这些位置。这个细节在多数论文里不会写,但不做的话指标会偏高。

3. 从 VOC 原始标注到 pascal-5i 可训练格式:转换脚本与四个边界坑

3.1 目录结构怎么摆、文件命名怎么对齐

PASCAL VOC 2012 的原始结构是 JPEGImages、SegmentationClass、ImageSets/Segmentation 三件套。pascal-5i 本身不改变图像和标注文件,只改变你读取时的类别映射和采样逻辑。所以转换工作的核心是:建立类别名到连续 id 的映射,并生成每个 fold 的 base/novel 列表。

常见做法是保留原始 PNG 标注,在代码里做 remap。我一般会生成一个 json 文件,记录每个 fold 的类别 id 映射,避免每次训练时重新算。

import json import numpy as np from PIL import Image # PASCAL VOC 2012 的 21 类(含背景)原始 id 映射 VOC_CLASSES = [ "background", "aeroplane", "bicycle", "bird", "boat", "bottle", "bus", "car", "cat", "chair", "cow", "diningtable", "dog", "horse", "motorbike", "person", "pottedplant", "sheep", "sofa", "train", "tvmonitor" ] def build_remap(fold_name, split_info): """为指定 fold 生成 0..N 的连续 id 映射,背景固定为 0""" remap = {0: 0} # 背景 # base 类从 1 开始编号 for idx, cls in enumerate(split_info["base"], start=1): remap[VOC_CLASSES.index(cls)] = idx # novel 类接着编号,但训练时会被 ignore for idx, cls in enumerate(split_info["novel"], start=len(split_info["base"]) + 1): remap[VOC_CLASSES.index(cls)] = idx return remap def convert_mask(mask_path, remap, ignore_index=255): """把原始 mask 按 remap 转换,未映射的类别置为 ignore_index""" mask = np.array(Image.open(mask_path)) out = np.full_like(mask, ignore_index, dtype=np.uint8) for src, dst in remap.items(): out[mask == src] = dst return out

参数说明:remap 的 key 是原始 VOC id,value 是训练时用的连续 id。ignore_index 通常设 255,和多数分割框架的默认值一致。逻辑上,背景必须保留为 0,否则模型会把背景当普通类处理。如果你发现训练 loss 一直不降,先检查 remap 里有没有漏掉某个类。

3.2 四个边界坑:图像级过滤、像素级过滤、类别不平衡、评测脚本

第一个坑是图像级过滤不等于像素级过滤,前面已经提过。第二个坑是类别不平衡:base 类里 person 和 car 的像素量远大于其他类,直接训练会让模型偏向大头类。常见做法是在 loss 里加类别权重,或者对稀有类做过采样。第三个坑是评测脚本:很多开源实现的 mIoU 计算方式不一致,有的按类平均,有的按像素平均。pascal-5i 的标准是按类平均,且只统计 novel 类。第四个坑是 support 集的标注质量:PASCAL VOC 的标注本身有噪声,1-shot 时如果抽到一张标注很差的 support,整个 episode 的指标都会崩。我一般会在采样时加一个简单的质量过滤,比如 support 的前景像素占比低于 1% 就重新抽。

def is_valid_support(mask, min_ratio=0.01): """support 前景像素占比过低时判为无效,重新采样""" fg = (mask > 0).sum() total = mask.size return (fg / total) >= min_ratio

这个过滤不是论文里的标准操作,但在实际复现时能明显降低 episode 间的方差。参数 min_ratio 我一般设 0.01,设太高会导致采样失败率上升,设太低起不到过滤作用。

4. 避坑与排查:pascal-5i 复现时最常见的 5 个翻车现场

4.1 指标虚高:novel 类标签泄漏进训练

现象:训练时 mIoU 正常,测试 novel 类时高得离谱,甚至超过 base 类。原因:训练时没有把 novel 类像素置为 ignore_index,或者 DataLoader 里混入了 novel 类的样本。解决:在 mask 转换阶段显式检查 novel 类 id 是否被置为 255,并在训练循环里打印每个 batch 的 unique 值,确认没有 novel 类 id 出现。

4.2 指标虚低:support 和 query 图像重叠

现象:测试指标远低于论文报告值,且不同 episode 之间波动极大。原因:采样时 support 和 query 从同一个图像池里抽,导致同一张图既当 support 又当 query。解决:在 EpisodicSampler 里维护已用图像集合,或者直接在采样前把图像列表打乱后按顺序切分,确保不重叠。

4.3 训练不收敛:remap 后类别 id 不连续

现象:loss 震荡或直接 NaN。原因:remap 时跳过了某些 id,导致类别数对不上模型输出通道数。解决:打印 remap 的 value 集合,确认是 0 到 N 的连续整数,且 N 等于模型输出通道数减一。

4.4 评测结果和论文对不上:mIoU 计算方式不一致

现象:同样的模型和权重,你的 mIoU 比论文低 5 到 10 个点。原因:有的实现按像素平均,有的按类平均,还有的把背景也算进去了。解决:统一按类平均,只统计 novel 类的 5 个类,背景不计入。可以在评测脚本里加一行打印每个类的 IoU,方便定位是哪个类拖后腿。

4.5 显存爆炸:episodic 采样时 batch 内图像尺寸不一致

现象:训练到一半 OOM。原因:PASCAL VOC 图像尺寸不统一,episodic 采样时一个 batch 里混了不同尺寸的图。解决:在 Dataset 里统一 resize 到固定尺寸,比如 473x473 或 513x513,并同步 resize mask,用最近邻插值避免引入新类别。

5. 用 1-shot 结果反推数据管线是否健康:一个可复现的验证技巧

最后一章讲一个我常用的验证方法:不要一上来就训大模型,先用一个极简的 prototype 分割器跑通全流程,用 1-shot 的指标反推数据管线有没有问题。具体做法是:对每个 episode,把 support 的前景像素取平均得到一个 prototype 向量,然后对 query 的每个像素算余弦相似度,阈值化后得到分割结果。这个方法不需要训练,纯推理,几分钟就能跑完一个 fold。

import torch import torch.nn.functional as F def prototype_segment(support_feat, support_mask, query_feat, threshold=0.5): """ support_feat: [N_shot, C, H, W] support_mask: [N_shot, H, W],值为 0/1 query_feat: [C, H, W] 返回 query 的二值分割结果 """ # 1. 对 support 的前景像素做平均,得到 prototype fg_feats = [] for i in range(support_feat.shape[0]): mask = support_mask[i].unsqueeze(0) # [1, H, W] fg = support_feat[i] * mask # 只保留前景 fg_feats.append(fg.sum(dim=(1, 2)) / (mask.sum() + 1e-6)) proto = torch.stack(fg_feats).mean(dim=0) # [C] # 2. 对 query 每个像素算余弦相似度 C, H, W = query_feat.shape q = query_feat.permute(1, 2, 0).reshape(-1, C) # [H*W, C] q = F.normalize(q, dim=1) p = F.normalize(proto.unsqueeze(0), dim=1) # [1, C] sim = (q @ p.T).reshape(H, W) # [H, W] # 3. 阈值化得到二值 mask return (sim > threshold).float()

参数说明:threshold 一般取 0.5,但实际用的时候可以扫 0.3 到 0.7,看哪个值在验证集上最稳。逻辑上,这个方法的指标肯定不如训练过的模型,但如果你的数据管线有问题,它的指标会异常低,比如 novel 类 mIoU 低于 10,那就说明 support 和 query 的类别对不上,或者 mask 转换错了。我一般会先用这个方法跑一遍 fold-0,确认 mIoU 在 30 左右,再开始训正式模型。如果低于 20,先回去查第 3 章的 remap 和采样逻辑,别急着调模型。

还有一个习惯:每次换 fold 或换 backbone,我都会固定随机种子跑 3 个 episode,看指标的方差。如果方差超过 5 个点,说明采样不稳定,需要加 support 质量过滤或者增大 n_query。这个习惯帮我省了很多后悔药,因为小样本分割的指标对采样极其敏感,不控制方差的话,你根本分不清是模型变好了还是抽到了简单 episode。

希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询