机器学习数据集划分实战:以Oxford Flower102为例详解分层抽样与避坑指南

发布时间:2026/8/3 3:09:25
机器学习数据集划分实战:以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, fclass_{i}), exist_okTrue) # 加载标签 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, keyclass_counts.get) max_class max(class_counts, keyclass_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_sizetest_ratio, random_stateseed, shuffleTrue ) # 然后从剩余数据中分出验证集 # 注意这里需要根据剩余比例计算新的test_size relative_val_ratio val_ratio / (train_ratio val_ratio) train_imgs, val_imgs train_test_split( train_val_imgs, test_sizerelative_val_ratio, random_stateseed, shuffleTrue ) # 记录分配结果 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, fclass_{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 使用现有工具库简化流程对于常见数据集和标准划分我们可以利用现有工具避免重复造轮子。TorchvisionPyTorch的torchvision.datasets.Flowers102已经内置了下载和标准划分train, val, test。你可以直接使用。from torchvision.datasets import Flowers102 # 下载并加载split 可以是 train, val, test train_dataset Flowers102(root./data, splittrain, downloadTrue, transform...)但了解其背后的划分逻辑对于处理自定义数据集至关重要。Scikit-learnsklearn.model_selection中的StratifiedShuffleSplit或StratifiedKFold可以更方便地进行分层划分。from sklearn.model_selection import StratifiedShuffleSplit sss StratifiedShuffleSplit(n_splits1, test_size0.2, random_stateseed) # 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(brightness0.2, contrast0.2, saturation0.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, transformtrain_transform) val_dataset datasets.ImageFolder(root./flower102_split/val, transformval_test_transform) test_dataset datasets.ImageFolder(root./flower102_split/test, transformval_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_sizebatch_size, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_dataset, batch_sizebatch_size, shuffleFalse, num_workers4, pin_memoryTrue) test_loader DataLoader(test_dataset, batch_sizebatch_size, shuffleFalse, num_workers4) # 检查一个批次的数据 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. 确保文件夹命名与代码中fclass_{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通过上述分层、随机、可复现的划分方法你就能得到一个可靠的数据基准可以安心地投入到模型设计、训练和调参的后续工作中去了。