Pytorch数据集预处理实战:从自定义Dataset到DataLoader全流程

发布时间:2026/9/16 18:03:09
Pytorch数据集预处理实战:从自定义Dataset到DataLoader全流程
动手写Pytorch入门教程这个系列时我一直在想第一篇文章到底该从哪里切入。环境搭建、张量基础、自动求导这些当然都是绕不开的但真正让我在带新人时感到头疼的反而是最不起眼的数据准备环节。很多人辛辛苦苦装好了Pytorch跑通了官方demo一换到自己的数据集上就卡住模型训练起来 loss 乱跳甚至直接报错最后发现根源往往不是模型结构写错了而是数据压根没喂对。这篇文章就围绕“数据集预处理准备训练”这条主线展开以 ISIC2017 皮肤镜图像数据集为例把从原始图片到可训练 DataLoader 的完整链路拆开讲清楚。无论你手里是图像分类、分割还是检测任务这套思路和代码骨架几乎都能直接平移过去尤其是自定义 Dataset 的写法、图像标准化参数的选择、数据增强的取舍这些问题属于换一百个数据集都躲不开的硬功夫。1. 环境准备与练习数据集的选型思路1.1 为什么用 ISIC2017 而不是 MNIST很多教程喜欢拿 MNIST 或者 CIFAR-10 当入门例子手写数字识别确实经典但有一个致命问题太顺了。torchvision 里直接就有现成的数据集类一行代码下载内置的 Dataset 封装得严严实实读者根本接触不到“自己写 Dataset”这个核心环节。等真正上手自己的项目面对一堆原始 JPG 和乱糟糟的标注文件立刻傻眼。ISIC2017 数据集不一样。它是皮肤镜图像公开数据集包含三类任务的数据分割、皮肤病损分类色素痣/脂溢性角化病/良性角化病、以及皮肤镜特征检测。最棒的一点在于它的原始文件组织方式——训练图像、分割掩码、分类标注是分开存放的文件名虽然有规律但需要自己手动配对这恰好还原了真实项目中“数据拿到手先要理清组织结构”的过程。更重要的是这些图像是真实拍摄的皮肤镜图片尺寸参差不齐几百到几千像素都有存在光照不均、毛发遮挡、肤色差异等噪声比 MNIST 那种规规整整的 28×28 灰度图接地气得多。处理这种数据时踩过的坑才是以后做任何视觉任务都会遇到的坑。1.2 基础环境检查与依赖确认关于 Pytorch 怎么安装网上的教程已经泛滥了我在这里只强调几个容易出问题的点。先说结论用 Anaconda 创建独立虚拟环境然后用 conda 或 pip 安装 Pytorch尽量不要直接装在 base 环境里。我见过太多人图省事把所有包都堆在 base 里最后某个依赖冲突只能整个环境推倒重来。# 创建虚拟环境Python 版本建议 3.9 或 3.10 conda create -n torch_learning python3.10 -y # 激活环境 conda activate torch_learning安装 Pytorch 时CPU 版本和 GPU 版本的区别主要在安装命令上。这里提醒一句如果你不玩大模型、不看视频教程学习只是做入门学习和跑跑图像分类/分割实验CPU 版本其实完全够用。但如果你手头有 NVIDIA 显卡且打算认真深入就装上 CUDA 版本# CPU 版 pip install torch torchvision # GPU 版具体 CUDA 版本号以 pytorch 官网为准 pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121装完后务必在命令行验证一下import torch print(torch.__version__) print(torch.cuda.is_available())torch.cuda.is_available()返回True才说明 GPU 版真的能用。这里有个常人容易忽视的细节很多人装完 GPU 版发现这个函数返回False第一反应是安装包的问题其实大概率是 CUDA 驱动版本不对或者 pytorch 的 CUDA 编译版本和显卡驱动不匹配。解决办法很简单——更新显卡驱动到最新然后再试基本能解决九成的问题。2. 拿到 ISIC2017 后先别急着写代码——数据组织结构梳理2.1 下载与目录解压ISIC2017 的数据可以通过官方网站申请下载如果你不想注册麻烦也可以找找国内镜像或者 Kaggle 上的副本。下载下来通常是一个压缩包包含以下几个部分ISIC-2017_Training_Data训练集的原始皮肤镜图像大约 2000 张 JPGISIC-2017_Validation_Data验证集图像约 150 张ISIC-2017_Test_Data测试集图像约 600 张ISIC-2017_Training_Part1_GroundTruth训练集的分割掩码PNG二值图ISIC-2017_Training_Part2_GroundTruth训练集的分类标注CSV 格式三分类同理还有验证集和测试集的对应标注解压后建议把目录整理成这样dataset/ ├── train/ │ ├── images/ # 训练图像 .jpg │ ├── masks/ # 训练分割掩码 .png │ └── labels.csv # 训练分类标注 ├── val/ │ ├── images/ │ ├── masks/ │ └── labels.csv └── test/ ├── images/ └── masks/这样归类的好处是后续写代码时路径逻辑清晰不至于在一个多级嵌套的原始目录里绕来绕去。2.2 图像与掩码的对应关系这里藏着第一个坑。ISIC2017 的图片文件名格式是ISIC_0000001.jpg这样的编号分割掩码的名称却是ISIC_0000001_segmentation.png多了一个_segmentation后缀。初学者如果没搞清楚这层对应关系直接按文件名匹配就会得到一堆缺失值。而分类标注的 CSV 文件长这样image_id,melanoma,seborrheic_keratosis ISIC_0000001,0.0,0.0 ISIC_0000002,1.0,0.0前两列是对应两种疾病的概率得分若两列都是 0说明这张图属于第三类良性痣。所以做分类任务时需要根据这两列的取值合成一个目标标签label 0 # 良性痣 if row[melanoma] 1.0: label 1 # 黑色素瘤 elif row[seborrheic_keratosis] 1.0: label 2 # 脂溢性角化病这个映射关系不搞清楚后面训练时目标标签全部错位模型学出来的东西就是垃圾进垃圾出。2.3 一个容易踩的坑文件名后缀与大小写ISIC2017 原始数据里的图片文件名后缀是.jpg但有些批量下载工具或手动重命名时可能会存成.JPG。在 Windows 上这无所谓文件系统不区分大小写一旦换到 Linux 服务器上训练代码直接报FileNotFoundError。保险的做法是在数据处理脚本里统一做大小写归一化import os from pathlib import Path def normalize_suffix(path_dir): 将目录下所有图片后缀统一为小写 jpg for f in Path(path_dir).iterdir(): if f.suffix .JPG: f.rename(f.with_suffix(.jpg))这种细节看起来不起眼但真的能省下一整晚的排查时间。我在带项目时见过不止一个同学在服务器上因为这个问题卡了半个多小时最后发现只是后缀大小写不一致。3. 图像预处理的三个层次从物理尺寸到像素值3.1 为什么不能直接拿原始图片训练ISIC2017 的图像分辨率极不规整有的图是 767×1022有的是 1022×767还有的是几千像素的大图。如果直接把原图塞进卷积神经网络由于卷积和池化操作会逐步降低特征图尺寸最后在全连接层之前需要特征图尺寸一致否则就会报维度不匹配的错误。就算你用全局平均池化Global Average Pooling绕开这个问题不同尺寸的图片在 batch 内也没法直接堆叠成张量。尺寸不统一的图片经过torch.stack时会直接报错这个错误信息还特别不好读。所以第一件事永远是统一尺寸。3.2 尺寸统一与等比缩放常见的做法是缩放后中心裁剪Resize CenterCrop或者直接缩放Resize。两者适合的场景不一样直接缩放简单粗暴但会改变图像宽高比对皮肤镜图像而言可能造成病灶形态的轻微畸变Resize CenterCrop先缩放到略大的尺寸再从中心裁剪到目标尺寸能在保留大部分信息的同时尽量保持宽高比适合目标在图像中心区域的任务ISIC 的病灶通常就在中心。我个人的建议是分类任务优先用 Resize CenterCrop分割任务则尽量保持原始宽高比只用 Resize 到目标尺寸。代码实现如下from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomHorizontalFlip(), transforms.RandomRotation(15), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])注意这里的Resize((256, 256))是直接把所有图拉到 256×256不管原始比例。如果你用了 Resize CenterCrop那就是先Resize((288, 288))再CenterCrop((256, 256))。3.3 标准化为什么用 ImageNet 的均值和方差Normalize(mean, std)这一步的目的是把像素值从 [0, 1] 的分布转换到接近标准正态分布。这样做能加速模型收敛因为神经网络的权重初始化通常假设输入数据是零均值、单位方差的。Pytorch 官方预训练模型的输入归一化参数用的是 ImageNet 数据集的统计值mean[0.485, 0.456, 0.406]std[0.229, 0.224, 0.225]。如果你打算用预训练模型做迁移学习这一步最好保持不变因为预训练权重是在这个分布下学习出来的输入分布差距太大会让微调效果大打折扣。但如果你是从零训练模型且数据是医学图像这种特殊分布那更稳妥的做法是在训练集上统计自己的 mean 和 stdimport torch from torch.utils.data import DataLoader from torchvision import datasets, transforms # 先用一个只含 ToTensor 的 transform 加载数据统计像素分布 stat_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.ToTensor() ]) dataset datasets.ImageFolder(dataset/train, transformstat_transform) loader DataLoader(dataset, batch_size64, shuffleFalse, num_workers4) mean_sum 0.0 std_sum 0.0 total 0 for images, _ in loader: # images shape: (batch, channels, height, width) batch_pixels images.size(0) * images.size(2) * images.size(3) mean_sum images.mean(dim[0, 2, 3]) * batch_pixels total batch_pixels mean mean_sum / total # 再跑一遍统计标准差 var_sum 0.0 for images, _ in loader: batch_pixels images.size(0) * images.size(2) * images.size(3) var_sum ((images - mean.view(1, -1, 1, 1)) ** 2).sum(dim[0, 2, 3]) std torch.sqrt(var_sum / total) print(fmean: {mean}, std: {std})这段代码的思路是先算均值用均值算方差再开根号得标准差。实际操作中为了节省时间也可以只统计一个随机子集。不过对入门学习而言直接用 ImageNet 的参数也完全没有问题毕竟 ISIC2017 的图像也是自然图像范畴分布并没有偏离到不可用的程度。3.4 数据增强要不要用、怎么用数据增强是对训练集做随机变换让模型看到更多样化的输入从而降低过拟合风险。初学者常见的一个误区是“数据增强越多越好”其实不然。ISIC2017 的医学图像有特殊性像 RandomRotation 如果角度太大可能把病灶旋转到不自然的方向引入噪声RandomVerticalFlip 和 RandomHorizontalFlip 这类镜像操作对皮肤镜图像比较友好因为病灶左右翻转并不会改变其医学本质。增强操作是否推荐原因RandomHorizontalFlip推荐镜像不影响病灶形态低成本扩容RandomVerticalFlip推荐同上RandomRotation(±15°)推荐小角度旋转模拟拍摄角度变化RandomResizedCrop谨慎使用可能裁掉病灶主体分割任务尤其小心ColorJitter谨慎使用色调变化可能改变皮肤镜图像的诊断特征RandomErasing / Cutout可选模拟遮挡但对医学图需评估收益另外必须记住数据增强只用于训练集验证集和测试集只能做尺寸缩放和标准化。否则你评估模型的指标里混入了增强带来的随机噪声结果会失真。4. 自定义 Dataset 类的完整写法与背后逻辑4.1 为什么必须自己写 Dataset虽然 torchvision 提供了ImageFolder这种懒人工具但它的前提是数据集按类别分文件夹存放。ISIC2017 的标注在 CSV 里图片和掩码又分离在两个目录用ImageFolder就很别扭。这时候就需要继承torch.utils.data.Dataset写自己的数据集类。网上很多教程把写 Dataset 讲得太玄乎其实核心只有三个必须实现的方法__init__初始化确定数据来源文件路径列表、标注表等并定义 transform__len__返回数据集样本总数DataLoader 根据这个值计算迭代轮次__getitem__接收一个索引加载第 index 个样本的图像和标签并返回元组(image, label)。4.2 惯用实现惰性加载这里有个重要的设计思想__init__只保存文件路径和标注信息真正读图的操作放在__getitem__里。这样创建 Dataset 时不需要加载所有图像进内存而是每次迭代按需读取。一张 ISIC 原图可能十几兆2000 张全部预加载就是几十 GB 内存不现实。下面的代码是一个可直接用于 ISIC2017 分类任务的完整示例import os import pandas as pd from PIL import Image from torch.utils.data import Dataset class ISIC2017Dataset(Dataset): def __init__(self, img_dir, label_csv_path, transformNone): img_dir: 图像所在文件夹路径 label_csv_path: 分类标注 CSV 路径 transform: torchvision.transforms 组合对象 self.img_dir img_dir self.df pd.read_csv(label_csv_path) self.transform transform # 构建类别标签 labels [] for _, row in self.df.iterrows(): if row[melanoma] 1.0: labels.append(1) # 黑色素瘤 elif row[seborrheic_keratosis] 1.0: labels.append(2) # 脂溢性角化病 else: labels.append(0) # 良性痣 self.df[label] labels def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] img_path os.path.join(self.img_dir, row[image_id] .jpg) image Image.open(img_path).convert(RGB) label row[label] if self.transform: image self.transform(image) return image, label几个需要注意的细节Image.open()不会立即把像素数据读进内存但.convert(RGB)这一步会强制加载同时把灰度图或 RGBA 图统一转成三通道 RGB。ISIC2017 的掩码 PNG 是单通道如果做分割任务千万别对掩码做convert(RGB)否则标签图变成三通道计算损失时会出问题。__getitem__里返回的image要求是torch.Tensor所以 transform 的最后一步通常是ToTensor()它会把 PIL.Image 或 numpy 数组的像素值从 [0, 255] 缩放到 [0.0, 1.0]并自动把 HWC 的排布转换成 PyTorch 习惯的 CHW 排布。如果数据集很大CSV 较大pd.read_csv读进来所占的内存可能很高可以考虑只读取需要的列self.df pd.read_csv(label_csv_path, usecols[image_id, melanoma, seborrheic_keratosis])4.3 分割任务的数据集有什么不同如果把 ISIC2017 用作分割任务那__getitem__就要同时返回图像和掩码class ISICSegDataset(Dataset): def __init__(self, img_dir, mask_dir, img_ids, transformNone, mask_transformNone): self.img_dir img_dir self.mask_dir mask_dir self.img_ids img_ids self.transform transform self.mask_transform mask_transform def __len__(self): return len(self.img_ids) def __getitem__(self, idx): img_id self.img_ids[idx] img_path os.path.join(self.img_dir, img_id .jpg) mask_path os.path.join(self.mask_dir, img_id _segmentation.png) image Image.open(img_path).convert(RGB) mask Image.open(mask_path).convert(L) # 单通道灰度 if self.transform: image self.transform(image) if self.mask_transform: mask self.mask_transform(mask) # 掩码二值化像素值变成 0/1 mask (mask 0.5).float() return image, mask注意掩码的处理和图像不同掩码不能做标准化因为它是标注不是图像特征。大多数分割损失函数如 Dice Loss、CrossEntropy Loss期望的标签是整数或 0/1 浮点数所以这里把归一化后的掩码再二值化回 0/1。同时如果对图像做了随机翻转或旋转掩码必须做完全相同的变换否则图像和标签就对不上了。这就意味着你不能简单用同一个 transform 组合而是要保证随机种子在图像和掩码变换之间保持一致或者调用torchvision.transforms里支持同时对 image 和 mask 做同一组变换的函数。实在不行可以借助第三方库如 albumentations它对“图像掩码同步增强”支持非常友好。5. 划分训练集、验证集和测试集以及随机种子的坑5.1 为什么训练时永远不要碰测试集很多新手犯的一个错是把数据集按 7:3 分成训练集和测试集然后用测试集来调超参数、选模型反复在测试集上看效果。这种做法在学术上叫“数据泄漏”因为你在测试集上迭代了太多次模型间接“记住”了测试集的特性最后的评估分数是虚高的。正确的做法是划分出三个集合训练集、验证集、测试集。训练集用来更新模型参数验证集用来调超参数和选择模型比如决定何时早停测试集只在整个流程快结束时使用一次用来估计模型在真实场景中的泛化能力。如果你的数据量不大比如 ISIC2017 的训练集只有 2000 张可以考虑 70% 训练、15% 验证、15% 测试的划分方式。5.2 按文件列表划分而不是移动文件ISIC2017 官方已经把训练/验证/测试集分好了理论上不需要自己再划分。但如果遇到没有官方划分的数据集最便捷的方式是用sklearn.model_selection.train_test_split对文件 ID 列表做划分而不是真的移动文件。from sklearn.model_selection import train_test_split img_ids list(df[image_id].values) train_ids, val_ids train_test_split(img_ids, test_size0.2, random_state42)这样划分出的 ids 直接传给自定义 Dataset 的构造函数即可。好处是数据在磁盘上只存一份不会因为复制粘贴白白浪费一倍存储空间也方便后续随时调整划分比例。5.3 随机种子一个看起来不起眼但毁掉复现的细节train_test_split里的random_state42是一个固定随机种子目的就是让每次运行代码时划分结果完全一致。很多人训练时发现自己今天跑的结果和明天跑的结果对不上模型收敛曲线不一样甚至准确率差一两个点很大原因就是没有固定随机种子。需要在多个地方固定种子数据划分、数据增强的随机变换、模型初始化、DataLoader 的 shuffle。Pytorch 官方推荐的做法import torch import numpy as np import random def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) # 启用 cudnn 的确定性算法代价是稍微降低训练速度 torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark Falsebenchmark这个参数值得一提。默认情况下 Pytorch 会针对当前输入尺寸自动选择最合适的卷积算法以获得最快速度但这个选择过程存在随机性。固定它为False然后设置deterministic True就能保证每次卷积计算的结果一致。代价是训练速度会慢 5% 左右但换来复现性值得。6. DataLoader 参数配置细节与训练前自检6.1 batch_size、shuffle、num_workers 的合理取值Dataset 定义好后还要用 DataLoader 把它包一层DataLoader 负责把单个样本组装成批量张量并支持多进程预读取。from torch.utils.data import DataLoader train_dataset ISIC2017Dataset( img_dirdataset/train/images, label_csv_pathdataset/train/labels.csv, transformtrain_transform ) val_dataset ISIC2017Dataset( img_dirdataset/val/images, label_csv_pathdataset/val/labels.csv, transformval_transform ) train_loader DataLoader( train_dataset, batch_size32, shuffleTrue, # 训练集要 shuffle num_workers4, # 数据加载进程数 pin_memoryTrue, # 如果使用 GPU建议开启 drop_lastFalse ) val_loader DataLoader( val_dataset, batch_size32, shuffleFalse, # 验证集不要 shuffle num_workers4, pin_memoryTrue )几个参数逐个说清楚batch_size一次迭代喂给 GPU 的样本数。显存不够就调小显存有余就调大。ISIC 图像 256×256 的三通道图batch_size32 需要大约 6GB 显存加上中间激活值如果你的显卡只有 4GB建议改成 16 或者 8。入门推荐先用 16跑通了再往上加。shuffle训练集必须设为 True因为每个 epoch 都想让模型以不同的顺序看到数据避免它记住数据排列验证集和测试集设为 False。num_workers控制用几个子进程并预读取数据。设为 0 表示数据读取在主进程内完成调试代码时很方便实际训练时在 Linux 上可以设成 CPU 核数Windows 上如果遇到BrokenPipeError把num_workers调成 0 或 2 通常能解决。pin_memory开了之后会锁页内存加速从 CPU 到 GPU 的传输但会占用一部分内存显存富裕的机器建议开启。drop_last如果最后一个 batch 的样本数不足 batch_size有些损失函数如 BatchNorm 相关的结构在 batch size 很小的时候会不稳定这时可以设置 drop_lastTrue 丢掉最后一个不完整 batch但一般用默认 False 即可。6.2 训练前必做的一批数据可视化检查很多人代码写完后直接开始训练直到 loss 出现 nan 才发现数据喂错了。正确的姿势是训练前先可视化检查一个 batch 的数据。方法很简单从 loader 里取一批数据用 matplotlib 显示出来同时打印标签import matplotlib.pyplot as plt import torchvision.utils as vutils # 取出一个 batch data_iter iter(train_loader) images, labels next(data_iter) # 将多张图拼接成网格显示nrow 控制每行几张 grid vutils.make_grid(images, nrow8, normalizeTrue, scale_eachTrue) plt.figure(figsize(12, 12)) plt.imshow(grid.permute(1, 2, 0).numpy()) plt.axis(off) plt.show() print(labels[:16])检查几个关键点图像是否正常显示有没有全黑、全白或通道错乱的情况标签和图像内容是否匹配比如显示黑色素瘤的图标签是不是 1图像是否有过度的增强导致变形离谱。这一步花五分钟可能帮你省下五小时排错时间。我自己有一次 loss 死活不降最后可视化才发现 transform 里多写了一个transforms.Grayscale()把彩色图像全变成了灰度图模型当然什么都学不到。6.3 完整的 Pytorch 训练循环骨架数据准备工作最后一步是确保训练循环能跑通。下面的代码是一个最基础的训练循环保留了很多入门者需要关心的细节import torch import torch.nn as nn import torch.optim as optim from torchvision import models # 使用预训练 ResNet18修改最后一层分类数 model models.resnet18(pretrainedTrue) num_ftrs model.fc.in_features model.fc nn.Linear(num_ftrs, 3) # 三分类 device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-4) num_epochs 10 for epoch in range(num_epochs): # 训练阶段 model.train() running_loss 0.0 correct 0 total 0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() epoch_loss running_loss / total epoch_acc correct / total print(fEpoch [{epoch1}/{num_epochs}], Loss: {epoch_loss:.4f}, Acc: {epoch_acc:.4f}) # 验证阶段 model.eval() val_loss 0.0 val_correct 0 val_total 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) val_loss loss.item() * images.size(0) _, predicted torch.max(outputs, 1) val_total labels.size(0) val_correct (predicted labels).sum().item() val_loss / val_total val_acc val_correct / val_total print(fValidation Loss: {val_loss:.4f}, Validation Acc: {val_acc:.4f})这段代码里有几个新手容易踩的坑需要特别说明model.train()和model.eval()的切换。BatchNorm 和 Dropout 在训练和推理时行为不同model.train()告诉模型启用训练模式model.eval()切换到推理模式。很多人忘了切换导致验证集指标时好时差还以为是模型的问题。optimizer.zero_grad()的位置。必须在loss.backward()之前调用。Pytorch 的梯度是累积的不清零的话每轮迭代的梯度会叠加到一起导致梯度爆炸。验证集用torch.no_grad()包裹。推理阶段不需要计算梯度这样能省下大量内存和计算时间。迁移学习时的关键点resnet18(pretrainedTrue)加载的权重是针对 ImageNet 的 1000 类分类训练的最后一层全连接输出维度是 1000所以必须替换成自己任务的输出维度。这里model.fc nn.Linear(num_ftrs, 3)就是把最后的全连接层换成输出 3 类的版本。如果要在整个数据集上微调学习率不宜过高1e-4 是比较安全的起点。6.4 ISIC2017 类别不均衡问题的一个朴素处理ISIC2017 训练集中三类样本的数量并不均衡黑色素瘤明显少于良性痣。如果直接拿原始分布来训练模型会倾向于把所有样本都预测为多数类。处理类别不均衡的一个简单方法是在训练时给损失函数加权重class_weights torch.tensor([1.0, 2.0, 1.5]).to(device) criterion nn.CrossEntropyLoss(weightclass_weights)权重的具体数值要根据你数据集中的类别数量比例来定。比如良性痣有 1500 张黑色素瘤 400 张那黑色素瘤的权重就可以约为 1500/400 ≈ 3.75。先粗调再根据验证集效果微调。7. 一套顺手的数据预处理脚手架写到这把上面所有内容串成一个可复用的脚手架。我更倾向于把它保存成项目里的prepare_data.py每次开新项目直接改路径和少量参数而不是每次从头写。import os import torch import random import numpy as np from torch.utils.data import DataLoader, Dataset from torchvision import transforms from PIL import Image import pandas as pd def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) class ISIC2017Dataset(Dataset): def __init__(self, img_dir, ann_file, transformNone, modeclassification): self.img_dir img_dir self.df pd.read_csv(ann_file) self.transform transform self.mode mode if mode classification: self._build_class_labels() def _build_class_labels(self): labels [] for _, row in self.df.iterrows(): if row[melanoma] 1.0: labels.append(1) elif row[seborrheic_keratosis] 1.0: labels.append(2) else: labels.append(0) self.df[label] labels def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] img_path os.path.join(self.img_dir, row[image_id] .jpg) image Image.open(img_path).convert(RGB) if self.mode classification: label row[label] if self.transform: image self.transform(image) return image, label else: # 分割模式 mask_dir row.get(mask_dir) if mask_dir is None: raise ValueError(分割模式需要 mask_dir 列) mask_path os.path.join(mask_dir, row[image_id] _segmentation.png) mask Image.open(mask_path).convert(L) if self.transform: image self.transform(image) if self.mask_transform: mask self.mask_transform(mask) return image, mask def build_data_loader(img_dir, ann_file, batch_size32, shuffleTrue, num_workers2, transformNone, modeclassification): dataset ISIC2017Dataset( img_dirimg_dir, ann_fileann_file, transformtransform, modemode ) loader DataLoader( dataset, batch_sizebatch_size, shuffleshuffle, num_workersnum_workers, pin_memoryTrue ) return loader, dataset if __name__ __main__: # 以分类任务为例做测试 set_seed(42) train_tf transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) val_tf transforms.Compose([ transforms.Resize((256, 256)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) train_loader, _ build_data_loader( img_dirdataset/train/images, ann_filedataset/train/labels.csv, transformtrain_tf ) val_loader, _ build_data_loader( img_dirdataset/val/images, ann_filedataset/val/labels.csv, transformval_tf, shuffleFalse ) x, y next(iter(train_loader)) print(batch shape:, x.shape) # 期望 torch.Size([32, 3, 256, 256]) print(label shape:, y.shape) # 期望 torch.Size([32])把这个脚本直接跑一遍如果 batch shape 输出正常说明整个数据链路已经通了下一步就能安心写模型了。个人经验是数据预处理写得好不好决定了一次训练是“成功跑通”还是“辛苦调参”。很多做深度学习的朋友把注意力全放在模型结构上结果模型越改越复杂准确率却纹丝不动。回头检查数据链路才发现标准化参数用错了、数据增强太猛把病灶特征都抹掉了、或者训练集和验证集之间存在重叠。数据端多花的时间最后都会在训练和调参环节加倍省回来。至于要不要在数据层面花大力气做清洗和增强我的看法是第一步先把标准流程跑通性能不满意时再逐步加深数据处理的复杂度一步步验证每一步操作是否有正收益别一次性把所有花活都堆上。