机器学习数据集划分实战:以Oxford Flower102为例详解分层抽样与避坑指南
1. 项目概述:为什么数据集划分是模型成败的第一步
做机器学习项目,尤其是图像分类,拿到一个像Oxford Flower102这样的经典数据集,很多新手朋友会迫不及待地开始写模型代码。但根据我多年的经验,项目翻车往往不是模型不够复杂,而是在第一步——数据集划分上就埋下了隐患。今天,我们就以Oxford Flower102花卉分类数据集为例,深入聊聊如何科学、合理地将数据集划分为训练集、验证集和测试集。这看似是一个简单的文件操作,实则关系到模型评估的公正性、泛化能力的真实性,以及整个项目流程的可靠性。
Oxford Flower102数据集包含了102类英国常见花卉,每类有40到258张不等的图像,总计超过8000张。它的挑战在于类别不均衡,且图像在光照、角度、背景上存在较大差异。直接按文件名顺序简单切分,很可能导致某些类别的样本全部集中在某个子集中,让模型“偏科”。我们的目标,是构建一个能真实反映模型在未知数据上表现的评估框架。训练集用于模型学习,验证集用于在训练过程中调整超参数和选择模型,而测试集则是在一切尘埃落定后,对模型最终性能的“期末考试”,在整个训练和调参过程中必须保持“隔离”,绝不能偷看。接下来,我将拆解整个划分过程中的核心思路、具体操作、避坑技巧,并提供可直接复现的代码。
2. 核心思路与方案选型:不止是随机拆分那么简单
面对一个数据集,划分方案的选择取决于数据特性和项目目标。对于Oxford Flower102,我们不能采用最简单的全局随机划分,必须考虑类别不均衡和样本代表性。
2.1 分层抽样:确保每一类的“声音”都被听到
全局随机抽样(例如随机取80%做训练,10%验证,10%测试)在类别均衡的数据集上可行,但在Flower102上,对于样本数很少的类别(比如只有40张),随机抽样可能导致其在验证集或测试集中一张都没有,或者比例严重失调。这会使模型对该类别的性能评估变得毫无意义。
因此,分层抽样是我们的首选策略。它的核心思想是:在划分时,保持每个子集(训练、验证、测试)中各类别样本的比例,与原始数据集的类别比例基本一致。这样做能确保:
- 评估全面性:每个类别在验证集和测试集中都有代表,模型对所有类别的学习效果都能被评估。
- 减少偏差:避免因偶然因素导致某个子集类别分布极端,使得评估结果失真。
在实操中,我们通常按8:1:1或7:1.5:1.5等比例划分。考虑到Flower102总样本量约8000多张,8:1:1能保证训练集有足够的样本供模型学习,同时验证集和测试集也有一定规模(每类约4-26张)进行可靠评估。
2.2 随机种子:可重复性的基石
在划分数据时,我们依赖随机数生成器。如果不固定随机种子,每次运行脚本都会得到不同的划分结果,这会导致实验无法复现,结果无法对比。因此,固定随机种子是必须的。在Python中,这意味着需要设置random.seed()、numpy.random.seed(),如果使用scikit-learn的train_test_split,也需要设置random_state参数。这保证了任何人、在任何机器上,使用相同的代码和种子,都能得到完全一致的数据划分。
2.3 文件组织结构规划
清晰的目录结构是高效管理数据和代码的前提。划分完成后,我们建议采用如下结构:
flower102/ ├── train/ │ ├── class_1/ │ │ ├── image_0001.jpg │ │ └── ... │ ├── class_2/ │ │ └── ... │ └── ... ├── val/ (或 validation/) │ ├── class_1/ │ ├── class_2/ │ └── ... └── test/ ├── class_1/ ├── class_2/ └── ...这种按类别分文件夹的结构,被PyTorch的ImageFolder和TensorFlow的image_dataset_from_directory等标准数据加载器原生支持,后续使用起来非常方便。
注意:Oxford Flower102官方提供的
jpg图像文件通常在一个文件夹内,类别信息由单独的mat文件(如imagelabels.mat)或txt文件(如train.txt,val.txt,test.txt)给出。我们既可以直接使用官方划分(如果提供),也可以按照上述思路自己重新划分以进行对比实验。本文重点讲解自主划分的通用方法。
3. 实操全流程:从原始数据到标准三件套
假设我们已经从官网下载了Oxford Flower102数据集,得到了一个包含所有图片的jpg文件夹,以及存储标签的imagelabels.mat文件。下面我们一步步完成划分。
3.1 环境准备与数据探查
首先,我们需要解析标签文件,了解数据全貌。
import os import numpy as np import scipy.io as sio from sklearn.model_selection import train_test_split import shutil import random # 设置随机种子,确保可重复性 seed = 2024 random.seed(seed) np.random.seed(seed) # 路径设置 data_dir = './102flowers/jpg' # 图片所在文件夹 label_file = './102flowers/imagelabels.mat' # 标签文件 output_dir = './flower102_split' # 输出根目录 # 创建输出子目录 for split in ['train', 'val', 'test']: for i in range(1, 103): # 102个类别,编号从1开始 os.makedirs(os.path.join(output_dir, split, f'class_{i}'), exist_ok=True) # 加载标签 labels_mat = sio.loadmat(label_file) labels = labels_mat['labels'].flatten() # 形状 (8189,),每个元素是对应图片的类别编号(1-102) total_images = len(labels) print(f"总图像数量: {total_images}") print(f"类别数量: {len(np.unique(labels))}") # 统计每个类别的样本数 class_counts = {} for i in range(1, 103): count = np.sum(labels == i) class_counts[i] = count print(f"类别 {i}: {count} 张图片") # 查看最少的和最多的类别 min_class = min(class_counts, key=class_counts.get) max_class = max(class_counts, key=class_counts.get) print(f"\n样本最少的类别: {min_class}, 有 {class_counts[min_class]} 张") print(f"样本最多的类别: {max_class}, 有 {class_counts[max_class]} 张")运行这段代码,我们能立刻看到数据的不均衡性。这是决定我们采用分层抽样的直接依据。
3.2 实现分层划分与文件复制
接下来是核心步骤:对每个类别单独进行划分,然后汇总并移动文件。
# 获取所有图片文件名(假设按数字顺序命名,如 image_0001.jpg) image_files = sorted([f for f in os.listdir(data_dir) if f.endswith('.jpg')]) # 确保文件数量与标签数量一致 assert len(image_files) == total_images, "图片文件数量与标签数量不匹配!" # 按类别组织图片路径和标签 class_to_images = {i: [] for i in range(1, 103)} for idx, img_file in enumerate(image_files): class_id = labels[idx] class_to_images[class_id].append(img_file) # 定义划分比例 train_ratio = 0.8 val_ratio = 0.1 test_ratio = 0.1 # 三者之和为1 # 用于记录划分结果的字典 split_assignment = {img: None for img in image_files} # 对每个类别进行分层划分 for class_id, img_list in class_to_images.items(): if len(img_list) < 3: # 如果某个类别样本极少(少于3张),特殊处理:全部放入训练集,并在验证和测试时用其他策略(如交叉验证) print(f"警告: 类别 {class_id} 只有 {len(img_list)} 张图片,全部放入训练集。") for img in img_list: split_assignment[img] = 'train' continue # 首先分出测试集 train_val_imgs, test_imgs = train_test_split( img_list, test_size=test_ratio, random_state=seed, shuffle=True ) # 然后从剩余数据中分出验证集 # 注意:这里需要根据剩余比例计算新的test_size relative_val_ratio = val_ratio / (train_ratio + val_ratio) train_imgs, val_imgs = train_test_split( train_val_imgs, test_size=relative_val_ratio, random_state=seed, shuffle=True ) # 记录分配结果 for img in train_imgs: split_assignment[img] = 'train' for img in val_imgs: split_assignment[img] = 'val' for img in test_imgs: split_assignment[img] = 'test' # 验证划分比例 split_counts = {'train': 0, 'val': 0, 'test': 0} for img, split in split_assignment.items(): if split: split_counts[split] += 1 print(f"\n划分结果统计:") print(f"训练集: {split_counts['train']} 张 ({split_counts['train']/total_images:.2%})") print(f"验证集: {split_counts['val']} 张 ({split_counts['val']/total_images:.2%})") print(f"测试集: {split_counts['test']} 张 ({split_counts['test']/total_images:.2%})") # 复制文件到对应目录 print("\n开始复制文件...") for img_file, split in split_assignment.items(): if split is None: continue src_path = os.path.join(data_dir, img_file) # 获取该图片的类别ID idx = image_files.index(img_file) # 获取索引 class_id = labels[idx] dst_dir = os.path.join(output_dir, split, f'class_{class_id}') shutil.copy2(src_path, dst_dir) # 使用copy2保留元数据(如创建时间) print("文件复制完成!")3.3 划分后的检查与验证
文件复制完成后,绝不能认为万事大吉。必须进行检查,确保没有错误。
# 检查每个子集的类别分布 def check_split_distribution(split_name): split_path = os.path.join(output_dir, split_name) class_counts_split = {} for class_dir in os.listdir(split_path): if os.path.isdir(os.path.join(split_path, class_dir)): count = len([f for f in os.listdir(os.path.join(split_path, class_dir)) if f.endswith('.jpg')]) class_counts_split[int(class_dir.split('_')[1])] = count return class_counts_split train_dist = check_split_distribution('train') val_dist = check_split_distribution('val') test_dist = check_split_distribution('test') # 计算并打印每个类别在三个子集中的比例 print("\n各类别在训练/验证/测试集中的数量分布(前10个类别):") for class_id in range(1, 11): total_in_class = class_counts[class_id] train_pct = train_dist.get(class_id, 0) / total_in_class if total_in_class > 0 else 0 val_pct = val_dist.get(class_id, 0) / total_in_class if total_in_class > 0 else 0 test_pct = test_dist.get(class_id, 0) / total_in_class if total_in_class > 0 else 0 print(f"类别 {class_id:3d}: 总数={total_in_class:3d} | 训练集={train_dist.get(class_id,0):3d}({train_pct:.1%}) | " f"验证集={val_dist.get(class_id,0):3d}({val_pct:.1%}) | 测试集={test_dist.get(class_id,0):3d}({test_pct:.1%})") # 检查是否有图片遗漏或重复 all_copied = sum(train_dist.values()) + sum(val_dist.values()) + sum(test_dist.values()) print(f"\n总复制图片数: {all_copied}") print(f"原始图片总数: {total_images}") assert all_copied == total_images, "图片数量不一致,可能存在遗漏或重复!"4. 高级策略与常见陷阱
基本的划分完成后,我们还需要考虑一些更复杂的场景和容易踩的坑。
4.1 处理极小样本类别
在Flower102中,虽然最少类别也有40张图,但如果你处理的是自定义数据集,可能会遇到某些类别只有个位数样本的情况。上述代码中我们简单地将它们全部归入训练集,但这并非最优解。
更好的策略:
- 数据增强:对这些稀有类别,在训练集中进行强力的数据增强(旋转、裁剪、颜色抖动等),人工扩充其有效样本量。
- 交叉验证:放弃为这些类别单独保留验证/测试集,而是采用交叉验证。例如,在训练时,将所有样本都用于训练,通过交叉验证来评估模型对该类的性能。在最终测试时,如果测试集中没有该类,则如实报告“该类因样本不足未参与最终测试”。
- 分层K折交叉验证:对于整个数据集,如果样本总量不大,可以直接采用分层K折交叉验证,这样能更充分地利用数据,特别是对于稀有类别。每一折都保证类别比例均衡。
4.2 避免数据泄露
数据泄露是导致模型“虚假高分”的元凶,在划分时必须严防死守。
- 基于图像的泄露:如果数据集中包含同一物体的不同角度拍摄、或同一张图片的不同裁剪,必须确保这些高度相关的图片被分到同一个子集(最好是训练集)。否则,模型在测试时看到与训练集高度相似的图片,会给出虚高的性能。对于Flower102,每张图是独立的花卉,这个问题不突出,但对于人脸识别、医疗影像(同一病人的多次扫描)则至关重要。解决方法是根据“主体ID”进行划分,而不是根据图片文件。
- 基于时间的泄露:对于时序数据,绝不能随机划分。必须按时间顺序划分(例如前80%时间的数据训练,后20%测试),否则就是用“未来”的数据预测“过去”,毫无意义。
- 预处理泄露:常见的错误是在划分之前对整个数据集进行标准化(如计算全局均值和方差)。正确做法是:先用训练集计算均值和方差,然后用这个统计量去标准化训练集、验证集和测试集。验证集和测试集必须使用从训练集“学来”的变换规则。
4.3 使用现有工具库简化流程
对于常见数据集和标准划分,我们可以利用现有工具,避免重复造轮子。
Torchvision:PyTorch的
torchvision.datasets.Flowers102已经内置了下载和标准划分(train, val, test)。你可以直接使用。from torchvision.datasets import Flowers102 # 下载并加载,split 可以是 'train', 'val', 'test' train_dataset = Flowers102(root='./data', split='train', download=True, transform=...)但了解其背后的划分逻辑,对于处理自定义数据集至关重要。
Scikit-learn:
sklearn.model_selection中的StratifiedShuffleSplit或StratifiedKFold可以更方便地进行分层划分。from sklearn.model_selection import StratifiedShuffleSplit sss = StratifiedShuffleSplit(n_splits=1, test_size=0.2, random_state=seed) # X是文件路径列表,y是标签列表 for train_index, test_val_index in sss.split(X, y): X_train, X_temp = X[train_index], X[test_val_index] y_train, y_temp = y[train_index], y[test_val_index] # 再对X_temp, y_temp进行一次划分得到验证集和测试集
5. 划分后的数据加载与模型训练衔接
数据划分好,目录结构清晰,后续的模型训练加载就非常顺畅了。这里以PyTorch为例,展示如何加载我们划分好的数据。
import torch from torchvision import datasets, transforms from torch.utils.data import DataLoader # 定义数据变换 train_transform = transforms.Compose([ transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_test_transform = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) # 创建数据集 train_dataset = datasets.ImageFolder(root='./flower102_split/train', transform=train_transform) val_dataset = datasets.ImageFolder(root='./flower102_split/val', transform=val_test_transform) test_dataset = datasets.ImageFolder(root='./flower102_split/test', transform=val_test_transform) print(f"训练集类别数: {len(train_dataset.classes)}") print(f"训练集样本数: {len(train_dataset)}") print(f"验证集样本数: {len(val_dataset)}") print(f"测试集样本数: {len(test_dataset)}") # 创建数据加载器 batch_size = 32 train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_dataset, batch_size=batch_size, shuffle=False, num_workers=4, pin_memory=True) test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False, num_workers=4) # 检查一个批次的数据 images, labels = next(iter(train_loader)) print(f"一个批次的图像形状: {images.shape}") # [32, 3, 224, 224] print(f"一个批次的标签形状: {labels.shape}") # [32]6. 实战中遇到的问题与排查清单
在实际操作中,你可能会遇到以下问题。这里是我的排查经验:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 模型在训练集上表现很好,在验证集上极差 | 1. 数据泄露(验证集和训练集数据高度相似或重复)。 2. 训练集和验证集的数据分布(如光照、背景)差异巨大。 3. 在划分时,验证集包含了训练集中没有的类别。 | 1. 检查图片文件名,确保无重复。对于相关图片组,确保它们在同一集合。 2. 可视化一些训练集和验证集的图片,观察分布差异。考虑更细粒度的分层(如按拍摄场景)。 3. 运行3.3节的检查代码,确认每个类别在训练和验证集中都有样本。 |
| 测试集性能远低于验证集性能 | 1. 在调参过程中,不知不觉地根据验证集结果对模型进行了“过拟合”。 2. 测试集的数据分布与训练/验证集有本质不同(例如,测试集是网上爬取的花卉图片,而训练集是实验室拍摄的)。 | 1. 严格遵守测试集只使用一次的原则。调参只看验证集指标。 2. 确保数据来源一致。如果必须使用不同分布的数据,应明确说明,并考虑使用领域自适应技术。 |
| 加载数据集时提示“找不到目录”或“空数据集” | 1. 文件复制过程中路径错误,导致目标文件夹为空。 2. ImageFolder期望的子目录名是类别名,而你的文件夹命名不符合预期(如用了class1而不是class_1)。 | 1. 检查flower102_split/train/等目录下是否有以class_开头的文件夹,以及里面是否有.jpg文件。2. 确保文件夹命名与代码中 f'class_{class_id}'的格式一致。ImageFolder会将子目录名直接作为类别名。 |
| 内存不足,无法一次性加载所有文件路径 | 数据集图片数量极大(远超Flower102的8000张)。 | 不要在列表中一次性存储所有文件路径。改为遍历原始目录,边读边处理边复制,或者使用生成器。对于超大数据集,可以考虑先划分出一个索引文件(记录每个文件属于哪个集合),然后按需加载。 |
| 类别不均衡导致模型偏向多数类 | 即使分层划分,训练集中各类别的绝对数量仍然差异很大(如最多类258张,最少类32张)。 | 1. 在损失函数中使用类别权重,给少数类更高的权重。 2. 在数据加载时进行过采样(重复少数类样本)或欠采样(丢弃部分多数类样本)。 3. 使用Focal Loss等专门处理类别不均衡的损失函数。 |
我的个人心得:
- 划分前先可视化:在写任何划分代码前,先用几行脚本随机查看几十张图片,对数据集的复杂度、质量、标注一致性有个直观感受。这能帮你预判可能的问题。
- 保存划分索引:除了复制文件,我强烈建议将划分结果(
{‘image_0001.jpg’: ‘train’, …})保存为一个JSON或CSV文件。这样,当你需要尝试不同的划分比例或方法时,无需重新复制文件,只需修改索引并重新生成软链接即可,节省大量时间和磁盘空间。 - 验证集是调参的“罗盘”:训练过程中,要像看仪表盘一样紧盯验证集损失和准确率。一旦发现验证集指标不再提升甚至开始变差(过拟合),就该考虑早停、增强正则化或调整数据增强了。测试集的那个数字,在项目最终报告前,最好把它忘掉。
- 小数据集的划分更要谨慎:如果整个数据集只有几百张图片,8:1:1的划分会让验证集和测试集太小,评估方差会很大。这时,分层K折交叉验证是更可靠的选择。你可以用K折交叉验证的结果来估计模型性能,并选择最佳参数,最后再用一个完全独立的、之前从未接触过的“保留集”做最终测试(如果数据够多的话)。
数据划分是机器学习项目的地基,地基打歪了,后面盖多高的楼都可能倾覆。花时间把这一步做扎实,理解每一种选择背后的考量,你的项目就成功了一半。对于Oxford Flower102,通过上述分层、随机、可复现的划分方法,你就能得到一个可靠的数据基准,可以安心地投入到模型设计、训练和调参的后续工作中去了。
