这几年做半监督学习实验,被问得最多的不是模型结构,而是“数据集到底怎么摆”。很多人论文里看半监督方法都挺顺,代码一跑就废,最后发现根子在于数据集结构没设计好:有标注的子集、无标注的子集、验证集、测试集混成一团,标签文件路径对不上,伪标签缓存乱写,评估结果失真。半监督学习的数据集,本质上不是把一个完整数据集扔给模型,而是要在数据层面先回答三个问题:哪些样本的标注是可信的、哪些样本要被当作无标注数据利用、以及验证和测试的边界在哪里。这篇文章就围绕这三个问题,把半监督学习数据集的基本结构彻底拆开讲清楚,包括目录组织、划分逻辑、伪标签的存储更新,以及我自己踩过的坑。
1. 理解半监督学习数据集的核心定位
1.1 半监督学习场景下“数据集”指的到底是什么
在讲结构之前,先对齐一个概念:半监督学习假设的是少量有标注数据加上大量无标注数据。这个假设落到工程实现上,意味着数据集必须显式区分出两类子集,而不是像监督学习那样一个 train 文件夹加一个 test 文件夹就完事。
我在实际项目里,通常直接把数据集组织成四个目录:labeled、unlabeled、val、test。labeled 里的样本带有确定标签,用于计算监督损失;unlabeled 里的样本没有标签,模型只能通过一致性正则化、伪标签或对比学习等方式间接利用;val 和 test 则承担模型筛选和最终评估职责。这里有一个常见的误区,就是有人把 val 和 test 也塞进 unlabeled 里,觉得反正模型没见过标签,可以当无标注数据用。这个操作非常危险,因为半监督方法通常会利用伪标签或特征一致性做模型选择,验证集一旦被模型间接学习过,最终评估指标的可靠性会大打折扣,实验结果也就不具备说服力。
再深一层说,半监督数据集的“结构”不只是文件夹的组织方式,还包括标签载体。对于图像分类任务,labeled 目录通常按类别分子文件夹,或者用一个 CSV 文件记录 image_id, label 的映射;unlabeled 目录则只是图片平铺,没有任何标签信息。对于目标检测、语义分割这类更复杂的任务,有标注子集里是真实的框或掩码标注,无标注子集则是一堆原始图片加一个空的标注占位。理解了这个层次,才能明白为什么半监督框架(例如 FixMatch 类算法)都需要一个额外的逻辑层来维护“哪些样本当前有标签”的状态,而不是单纯地在文件系统层面区分。
1.2 有标注与无标注的比例关系是怎么定出来的
比例关系是设计半监督实验的第一步。在真实的半监督基准里,常见设置是每个类只保留少部分标注样本,例如 CIFAR-10 上常用的方案是每类 4 个、25 个、100 个、400 个标注样本,对应总标注量分别是 40、250、1000、4000。MNIST 更激进,经常用到每类 10 个甚至 5 个,也就是总共 100 张或 50 张有标注样本。SVHN 则流行每类 100 个,总共 1000 张。ImageNet-1k 这类大数据集半监督设置则常用 1% 或 10% 的标注比例。
这些数字不是拍脑袋定的,它们决定了训练任务的难度以及无标注数据能起到的作用。标注比例越小,监督信号越弱,模型越依赖无标注数据的一致性约束,实验的随机波动也越大。我在自己的复现试验里发现,当 CIFAR-10 每类标注样本只有 4 个时,两个随机种子跑出来的准确率波动可以达到 2% 左右;而每类 100 个时,波动会缩到 0.5% 以内。因此,如果实验目的是验证算法有效性,建议从每类 100 个(总共 1000 个标注样本)开始跑;如果是为了复现论文里的高难题,再考虑每类 25 个或 4 个的极端设置。
这里还要注意一个问题:无标注子集和有标注子集的比例不仅影响精度,还会影响数据加载和缓存方案。总数一万张图片,分成 1000 张有标注和 9000 张无标注,存储、加载、增强这整套流水线,和五五开完全不同。半监督算法通常对无标注数据用更强的增强策略,这部分计算量往往占训练总耗时的一半以上,尤其是当无标注子集远大于有标注子集时,训练吞吐会明显变慢。设计数据集结构时,就要提前预估这些成本。
1.3 公开数据集如何改造成半监督实验设定
实际操作中,我们很少从零采集数据,更多是把公开数据集改造成半监督设定。MNIST、CIFAR-10、CIFAR-100、SVHN、STL-10、ImageNet-1k 都有各自的社区惯例。拿 CIFAR-10 来说,标准的做法是:从原始训练集 50000 张里随机抽出一部分作为 labeled,剩下全部作为 unlabeled;原始测试集 10000 张保持不变,作为评估测试集。实现时,并不需要真的把图片复制移动到两个文件夹,只需要维护一个索引文件,记录每个样本属于哪个子集即可。
这种按索引划分的方式,比物理拷贝文件要优雅得多。因为半监督实验经常要对比不同标注比例下的效果,如果用物理文件划分,每调整一次比例就要重新拷贝一份数据,既浪费磁盘空间又容易出错。索引方式只需要修改 CSV 里的 split 字段,数据增强时按索引读取即可。但索引方式也有缺点,就是伪标签缓存等中间状态需要额外存储位置,不能直接写在图片所在目录,否则会污染原始数据。
数据集的改造成本还取决于源数据的格式。像 COCO、VOC 这类检测数据集,半监督划分要额外考虑图片和标注文件的对齐。对于 HRSC2016、CCPD 这类场景较专一的数据集,一般没有现成的半监督划分标准,需要自己设计。我在做过一次车辆检测的半监督实验后,体会到这类数据集的划分重点不是随机抽样,而是要保证无标注子集里包含足够多与有标注子集相似但不完全相同的场景,否则模型学到的知识很难迁移。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据集的基本结构与组织方式
2.1 目录层面的标准组织方案
一个清晰可靠的半监督数据集目录,至少要能让人一眼看出“什么东西有标签、什么东西没标签、什么数据只用于评估”。我长期使用的方案是这样的:
code复制data/
├── labeled/
│ ├── class_a/
│ │ ├── img_0001.jpg
│ │ └── img_0002.jpg
│ └── class_b/
│ └── img_0100.jpg
├── unlabeled/
│ ├── img_5001.jpg
│ └── img_5002.jpg
├── val/
│ ├── class_a/
│ └── class_b/
├── test/
│ ├── class_a/
│ └── class_b/
└── splits/
├── labeled.txt
├── unlabeled.txt
├── val.txt
└── test.txt
labeled 目录按类别分子文件夹,好处是清洗数据时可以直接统计每个类别的样本数,判断有没有类不平衡问题。unlabeled 目录不做类别子文件夹,图片平铺或按采集批次归档,否则一旦分了子目录,等于隐式地给模型泄露了类别信息,这在某些半监督算法里会成为不公平的信息来源。val 和 test 单独放,我强烈建议,即使你的模型用不上验证集,也不要省这一步,因为后续调整超参数时,没有验证集就只能反复在测试集上碰运气。
splits 目录下的 TXT 文件是另一种索引形式,每一行是一张图片的相对路径。这种方式和文件夹结合起来,既可以做随机采样划分,又能保留原始文件不重复移动。当数据集规模达到几十万张时,文件系统层面去复制图片的成本很高,而用索引文件就只是一个文本文件的重写。我在做 ImageNet-1k 子集实验时,整个划分过程耗时不到一分钟,如果物理拷贝 ImageNet 的 128 万张图片,要占用大量磁盘空间和时间。
2.2 标签文件与索引文件的配合方式
索引文件是半监督数据集结构的核心。分类任务里,一个典型的 labeled.txt 长这样:
code复制images/class_a/0001.jpg 0
images/class_a/0002.jpg 0
images/class_b/0100.jpg 1
第一列是图片路径,第二列是类别 ID。unlabeled.txt 则只有路径,没有类别 ID。训练代码读取这些文件时,通过一个 Dataset 类来区分加载逻辑:有标签的样本返回 (image, label),无标签的样本返回 (image, ) 或者 (image, pseudo_label)。
这里有一个非常容易被忽视的细节:类别映射表必须固定不变。举个例子,CIFAR-10 的十个类别如果今天用字符串 'airplane' 编号为 0,明天又用字典序把 'bird' 编号为 0,那有标注子集的标签索引和模型输出头的顺序就全部错位,训练半天也收敛不了。我会在项目根目录放一个 class_names.txt,按固定顺序写入所有类别名称,并且用脚本验证索引文件的类别 ID 是否在这个范围内。
对于检测和分割任务,标注文件情况更复杂。COCO 格式的半监督数据集,labeled 子集对应的 JSON 里包含 annotations 字段,unlabeled 子集对应的 JSON 里 annotations 为空数组。这个空数组不是可选项,很多框架解析 JSON 时如果缺少 annotations 字段会直接报错,所以必须保证字段存在,只是内容为空。这个细节在我做半监督检测实验时,已经成了固定的检查点。
2.3 验证集和测试集为什么不能混入无标注子集
这个问题值得单独拿出来说。一些人认为,验证集和测试集虽然没有参与监督训练,但它们仍然可以被半监督模型利用——比如做伪标签一致性、做图结构传播、做特征统计。理论上这样做能提升一点结果,但代价是评估流程不再干净。
打个比方,这就像考试前把模拟题偷偷看了一遍,然后告诉自己“我只是参考一下题型,没有背答案”。验证集混入无标注子集,模型在做模型选择时,就会拿它已经“见过”的数据来评估,导致选择出来的是在验证集上过拟合的模型。测试集一旦被混入,最终准确率的含义就彻底变形了,论文里报的数字也就失去了可比性。
所以我的建议是:验证集和测试集在数据层面就不要出现在 unlabeled 目录里。具体做法是在划分索引时,先把原始训练集和原始测试集分开,再从原始训练集里切出 labeled 和 unlabeled;val 要么从原始训练集里再单独切一块出来,要么用原始测试集当作验证集。很多半监督论文实际上一半以上都是用原始测试集做模型选择的,这虽然不算最规范,但至少比把测试集混进无标注子集干净得多。
3. 核心细节拆解:从零构造半监督数据集
3.1 固定标签池与动态标签采样的取舍
构造半监督数据集的第一个关键操作,是决定有标注子集怎么采样。最常见的做法是固定标签池,也就是在训练开始前,随机选定一定数量的样本并固定下来,全程不变。这个做法复现性最好,是论文中最常用的设置。比如从 CIFAR-10 的 50000 张训练图片里,用固定随机种子 random.seed(2024) 为每个类别抽 100 张,共 1000 张有标注,剩余 49000 张无标注。
固定标签池有个明显的局限:如果选出的标注样本恰好集中在某个子分布上,比如全是某种光照条件或背景,模型学的特征就会有偏差。动态标签采样则允许训练过程中不断从无标注池里挑选新的样本加入标注池,比如使用基于置信度的采样策略。这种方式更接近实际业务场景里的主动学习,但复现性差,算法比较不同时很难公平对比。
我自己做研究对比时,坚持用固定标签池;做工程落地时,会额外保留一个动态采样的分支,用于处理真实业务中标注预算动态变化的场景。这里有一个重要参数:固定标签池的随机种子直接影响实验结果,论文里必须写清楚。我之前复现一篇论文时,因为没注意到它用的种子和我不一样,准确率差了 1.3 个百分点,排查了很久才发现是采样种子导致的随机性。
3.2 类别平衡策略和难例选择
半监督数据集和普通数据集的一个显著区别在于,有标注子集的类别分布严重影响着伪标签的质量。如果某个类别只标注了 2 张图片,而另一个类别标注了 200 张,那么模型大概率会在标注多的类别上产生高置信度伪标签,标注少的类别则被压制,导致半监督学习放大这种不平衡。
解决方法是分层采样,即 train_test_split 时使用 stratify=y 参数,保证每个类别在有标注子集中占比一致。不同类别标注数量差距巨大时,还有一种做法,是给无标注数据的损失按类别频率加权,频率低的类别权重更高。但这个策略需要在代码层面动态调整,数据集结构设计时就要预留类别权重表。
难例选择是另一个容易被忽略的点。无标注数据里难样本的比例,直接影响伪标签的可信度。如果无标注子集全是和标注样本差异很大的域外数据,模型预测的置信度普遍偏低,一致性正则化的效果也很差。我在做自动驾驶场景数据集时,有标注样本是白天城市道路,无标注样本里却混了大量夜间和雨天场景,结果初期模型几乎学不到有效信息,伪标签的准确率只有 52%。后来把无标注子集按场景均衡化,伪标签准确率提高到 79%,训练曲线才正常起来。
3.3 无标注数据的伪标签缓存结构
半监督学习模型训练过程中,对无标注数据会不断生成伪标签,这些伪标签可以存放在内存里,也可以落到磁盘上。数据集结构设计时,需要确定伪标签的存储位置和更新策略,否则容易出现读取错误或缓存过期问题。
我常用的伪标签缓存结构是一个文件夹 pseudo_labels/,下面按训练周期存放不同的文件,例如 epoch_10.npy、epoch_20.npy。每个文件是一个数组,长度等于无标注子集的样本数,数值是模型预测的类别 ID 或置信度。训练代码加载时,会根据当前 epoch 决定使用哪个缓存文件。这样做的好处是,可以在训练过程中反向验证伪标签质量,比如画出每轮伪标签的类别分布,观察是否出现类别崩溃。
对于更大规模的数据集,落盘方式建议用内存映射文件 numpy.memmap 或 HDF5,不要把所有伪标签一次性加载到内存。500 万张图片的数据集,伪标签数组就是 500 万个整数,看起来不大,但再加上置信度浮点数就是 40MB 左右,问题不大;真正的问题是每次读图时还要做增强和一致性计算,这部分内存和显存开销才是大头。
4. 实操过程与关键环节实现
4.1 用 PyTorch 实现一个标准半监督数据集的加载器
这里给出一个简洁但完整的 PyTorch 实现思路。先写一个 SemiDataset 类,接收 labeled_paths 和 unlabeled_paths 两个列表,然后分别定义 get_labeled_item 和 get_unlabeled_item 的返回逻辑。
python复制class SemiDataset(Dataset):
def __init__(self, labeled_paths, labeled_labels, unlabeled_paths,
weak_transform, strong_transform):
self.labeled_paths = labeled_paths
self.labeled_labels = labeled_labels
self.unlabeled_paths = unlabeled_paths
self.weak_transform = weak_transform
self.strong_transform = strong_transform
def __len__(self):
# 半监督batch通常由一批有标注和一批无标注组成
# 这里返回两者中较大的值,具体采样在外层循环控制
return max(len(self.labeled_paths), len(self.unlabeled_paths))
def get_labeled_item(self, idx):
img = Image.open(self.labeled_paths[idx]).convert('RGB')
img = self.weak_transform(img)
label = self.labeled_labels[idx]
return img, label
def get_unlabeled_item(self, idx):
img = Image.open(self.unlabeled_paths[idx]).convert('RGB')
img_w = self.weak_transform(img)
img_s = self.strong_transform(img)
return img_w, img_s
这里最重要的设计是让无标注样本同时返回弱增强和强增强两个版本,这是 FixMatch 类算法的数据流基础。实际训练时,外层循环会把有标注 batch 和无标注 batch 拼接起来,分别计算监督损失和无监督一致性损失。
另一个关键点是,有标注样本只做弱增强,是因为监督信号本身就强,不需要额外的强增强来提升泛化;而无标注样本必须做强增强,才能让模型在“变型后保持一致”这件事上学到更鲁棒的特征。这个设计直接影响最终效果,和数据集结构一样重要。
4.2 划分脚本的正确姿势和常见误区
划分半监督数据集的脚本看起来很简单,很多人觉得无非是几个随机抽样而已,但实际操作中有很多细节左右实验结果。我给出一个稳健的划分流程,按顺序做:
- 加载原始数据集,记录每个样本的路径和标签。
- 先把原始训练集和测试集彻底分离,测试集不动。
- 从原始训练集中切出
val子集,一般取总数的 5% 到 10%。 - 从剩余的原始训练集中,按类别分层抽样出
labeled子集。 - 剩余的所有样本全部归入
unlabeled子集。 - 分别写出四个索引文件,并打印每个子集的类别分布。
python复制import random
import numpy as np
random.seed(2024)
np.random.seed(2024)
# 假设 train_paths, train_labels, test_paths, test_labels 已加载
n = len(train_paths)
# 第一步:切验证集,固定 5000 张
val_idx = random.sample(range(n), 5000)
train_idx = list(set(range(n)) - set(val_idx))
val_paths = [train_paths[i] for i in val_idx]
val_labels = [train_labels[i] for i in val_idx]
# 第二步:按类别等量抽样有标注子集
# 假设每类抽 100 张
labeled_idx = []
for cls in range(num_classes):
cls_idx = [i for i in train_idx if train_labels[i] == cls]
labeled_idx.extend(random.sample(cls_idx, 100))
# 第三步:剩余全部为无标注
unlabeled_idx = list(set(train_idx) - set(labeled_idx))
注意,第 4 步里 random.sample(cls_idx, 100) 的前提是每个类别的样本数足够。如果某个类别只有 80 张,这个脚本直接会抛 ValueError。这种情况在半监督数据集里经常出现,尤其是长尾数据集,解决办法是调整策略:样本数少于设定值的类别,要么全部拿出来作为标注,要么在标注子集中允许类别不平衡,随后在训练时用类别权重补偿。不要硬把所有类别都抽成相同的数量,否则严重类别会直接崩溃。
另外,生成 unlabeled_idx 时要特别注意:它包含了所有被排除在 labeled 之外但同时又不是 val 的样本。如果脚本里不小心把 val_idx 又加进了 unlabeled_idx,就出现了前面提到的验证集污染问题。我在 DevOps 自动化流程里已经加了断言,在脚本末尾强制校验三个集合没有交集:
python复制assert len(set(labeled_idx) & set(unlabeled_idx)) == 0
assert len(set(labeled_idx) & set(val_idx)) == 0
assert len(set(unlabeled_idx) & set(val_idx)) == 0
这行断言成本极低,但能拦住绝大多数由划分顺序错乱导致的重复数据问题。
4.3 伪标签质量的监控与更新策略
数据集结构不是静态的,训练过程中伪标签缓存必须联动更新。我在实验中维护了两个关键指标来监控伪标签质量:一是无标注子集上预测置信度的均值,二是伪标签类别分布的熵。
置信度均值最能反映模型当前对无标注数据的把握程度。训练初期,模型还没收敛,置信度均值通常在 0.3 到 0.5 之间;随着训练进行,均值应逐渐上升,最终稳定在 0.8 以上。如果置信度均值上不去,很大可能是无标注子集里存在大量和标注分布差异大的样本,或者模型本身过拟合了标注子集。
伪标签类别分布的熵则用来发现类别崩溃。如果某个类别在 labeled 中有 100 张,但生成的伪标签里这个类别占了 60%,是一个非常强烈的信号,说明模型把大量无标注样本都预测成了这个类。半监督算法迭代到后期,这种现象会越来越严重,数据层面需要提前准备一份类别权重数组,用来在无监督损失中对高频类别降权。
伪标签更新的频率也很讲究。每轮迭代都重算所有无标注样本的伪标签,计算量太大;每 5 个 epoch 更新一次,是一个性价比不错的折中。实际操作时,我会在验证集准确率开始平稳后,固定每 10 个 epoch 重算一次伪标签并保存到新文件,这样既不浪费计算资源,又能保证训练早期使用较新模型生成的伪标签。
5. 常见问题与排查技巧实录
5.1 半监督实验结果复现不了的常见原因
以下问题我在复现别人工作和自己经验中都高频遇到,整理成一张速查表:
| 现象 | 常见原因 | 解决方向 |
|---|---|---|
| 训练初期的无监督损失振荡严重 | 有标注子集和无标注子集的分布差异过大 | 检查划分时的随机种子,统计两边的类别分布 |
| 有标注子集准确率高,但测试集准确率低 | 标注子集太小,模型过拟合;或验证集混入无标注子集 | 缩小模型容量,增强正则化;检查数据交叉 |
| 伪标签置信度均值长期低于 0.5 | 无标注子集包含大量域外样本 | 重新评估数据来源,做场景均衡或剔除离群样本 |
| 不同运行间结果差很多 | 划分种子的影响比想象中大,尤其在标注数量较少时 | 固定种子;多次运行取平均 |
| 伪标签类别分布严重倾斜 | 类别不平衡被半监督放大 | 调整损失权重;重新设计有标注子集采样策略 |
锚定这些问题会发现,可能模型结构和损失函数都是对的,数据集结构层面某个环节没做好,就会导致整个实验毫无意义。
5.2 如何判断无标注子集是否“干净”
“干净”这个词在半监督数据集里有两层含义。第一层是确认无标注子集没有泄露标签信息。具体来说,检查文件路径里是否包含类别名、检查一个样本是否同时出现在多个子集里、检查无标注子集里是否混入了重复图片。
第二层是确认无标注子集的分布与有标注子集没有灾难性偏移。我常用的办法非常简单:抽出一部分无标注样本,用训练好的模型输出嵌入表示,再用 UMAP 降到二维,直观地看无标注样本和标注样本的重叠情况。如果两类点几乎完全分开,说明分布差异太大,半监督学习很难从这些无标注数据中获益。
也可以做一个快速量化测试:先只用有标注子集训练一个小模型,用它预测无标注子集,看预测置信度分布。如果大部分样本置信度低于 0.3,说明无标注子集很可能存在分布偏移,需要进一步处理。
5.3 与小样本、不平衡数据集相关的实操心得
做小样本、不平衡数据的半监督学习时,数据集结构的设计更需要经验。比如在行星齿轮箱故障诊断这类工业场景中,故障样本极少,正常样本极多,数据集的类别分布严重偏斜。有标注子集如果按简单随机抽样,很可能某些故障类别只有寥寥几张,无标注子集却以正常样本为主,伪标签也会集中在正常样本上。
针对这种情况,我会在划分时提高故障类别的标注比例,优先保证小类别的标注样本数量。比如总标注预算只有 500 张,那么正常类最多分配 150 张,其余 350 张全部留给故障类。类失衡程度的标高,决定了半监督算法是否能学到有意义的类别边界。
还有一个心得是,伪标签的置信度阈值要根据类别动态调整。正常类样本量大,模型很快会给出高置信度预测;故障类样本少,置信度普遍偏低。这时候如果用一个全局阈值 0.95,可能根本筛不出几个故障类伪标签。调整为分阈值后,每个类别分别过滤,小类别的召回明显提升。
最后的小技巧
最后再分享一个在项目实践中帮了大忙的小技巧:在索引文件里给每个子集前面加一个 # comment 头,记录划分脚本的版本、随机种子、标注数量和生成时间。这样当实验结果异常时,可以快速回溯“这个数据集是谁在什么时候怎么生成的”。半监督实验的变量太多,模型结构、损失函数、增强策略都可能引入差异,数据集结构是少数几个容易失控又容易被忽略的变量。给数据打上版本标记,排查问题时会省下半天时间。我每次新建实验,第一件事都是检查数据集索引文件里的元信息,确认抽样子集和预期一致,然后才敢让训练脚本跑起来。
