数据预处理与加载优化:别让数据成为训练的瓶颈
说实话,很多同学把精力都花在调模型结构、调学习率上,结果训练一跑起来,GPU 利用率才 30%。我一看,好家伙,数据加载那块堵死了。数据预处理和加载,说白了就是给模型喂饭的。饭没准备好,或者喂得太慢,再好的厨师也白搭。
这一章,我们就聊聊怎么把「做饭」和「喂饭」这两个环节做到极致。
数据清洗与增强策略:喂给模型的东西要干净,还要花样多
数据清洗,听起来很基础,但坑最多。我见过有人直接把带 NaN 的数据丢给模型训练,Loss 直接飞了。也见过有人把标签搞反了,模型训了三天,准确率死活上不去。
常见的数据脏问题与处理
| 问题类型 | 典型表现 | 我的处理习惯 |
|---|---|---|
| 缺失值 | NaN, None, 空字符串 | 数值型用中位数填充,类别型用众数或单独标记 |
| 异常值 | 超出 3σ 或 IQR 范围 | 先可视化确认,再决定截断或剔除 |
| 重复样本 | 完全相同的行 | 直接去重,除非是时序数据 |
| 标签噪声 | 标注错误、标签不平衡 | 交叉验证检查,必要时做标签清洗 |
数据增强就不一样了。它是主动给数据「加料」,让模型见过更多样的情况。尤其在图像领域,增强几乎是标配。
常用的增强策略,我列个清单:
- 图像: 随机裁剪、翻转、色彩抖动、高斯噪声、CutMix、MixUp
- 文本: 同义词替换、回译、随机插入/删除
- 语音: 速度扰动、音量调整、加背景噪声
DataLoader 与多进程加载:把 GPU 喂饱,别让它闲着
模型训练时,GPU 算得飞快,但 CPU 读盘、解码、增强这些操作慢啊。如果只有一个进程在干活,GPU 就得等。这就是典型的「I/O 瓶颈」。
PyTorch 的 DataLoader 提供了多进程加载,说白了就是开好几个工人同时干活。一个工人负责读数据,另一个负责增强,第三个负责往 GPU 上送。流水线作业,效率翻倍。
from torch.utils.data import DataLoader, Dataset
class MyDataset(Dataset):
def __init__(self, data_list):
self.data = data_list
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
# 这里做数据读取和增强
sample = self.data[idx]
# 模拟耗时操作
return sample
# 关键参数:num_workers 和 pin_memory
dataloader = DataLoader(
MyDataset(my_data),
batch_size=64,
shuffle=True,
num_workers=4, # 开 4 个进程加载
pin_memory=True, # 锁页内存,加速 CPU->GPU 传输
prefetch_factor=2 # 每个 worker 预取 2 个 batch
)
这里有几个参数,我重点说一下:
- num_workers: 不是越大越好。我试过开到 16,结果 CPU 被打满,反而更慢。一般设为 CPU 核心数的一半或等量。
- pin_memory: 这个一定要开。它把数据固定在内存里,GPU 可以直接 DMA 读取,省掉一次拷贝。
- prefetch_factor: 预取多少个 batch。设大一点可以掩盖加载延迟,但会多占内存。
内存映射与磁盘 I/O 优化:数据太大,内存装不下怎么办?
当数据集大到几百 GB 甚至 TB 级,全部加载到内存里是不现实的。这时候就需要内存映射(Memory-Mapped File)技术。
说白了,就是把磁盘上的文件「映射」到虚拟内存地址空间。程序读写这个地址,就像读写内存一样快,但实际数据还在磁盘上。操作系统会自动按需加载。
我常用的做法是用 NumPy 的 memmap 或者 PyTorch 的 FileDataset。
import numpy as np
# 创建一个内存映射文件
fp = np.memmap('large_data.bin', dtype='float32', mode='r', shape=(1000000, 256))
# 读取第 1000 条数据,只加载这一块到内存
sample = fp[1000]
# 用完不用手动释放,系统会自动管理
磁盘 I/O 优化,核心就两点:
- 顺序读写优于随机读写。 把数据打包成 TFRecord、HDF5 或 MMap 格式,让磁盘磁头少来回移动。
- 使用 SSD 代替 HDD。 这个不用多说,SSD 的随机 I/O 性能比 HDD 高两个数量级。
数据预处理 Pipeline 设计:把流程串起来,还要能调试
一个完整的数据预处理 Pipeline,通常包含这几个阶段:
- 原始数据读取: 从磁盘或网络读取原始文件
- 解码与解析: 图片解码、JSON/CSV 解析
- 清洗与过滤: 去重、去异常、填充缺失
- 增强与变换: 数据增强、归一化、Tokenization
- 批处理与打包: 组装成 batch,送到 GPU
我习惯把 Pipeline 设计成「可插拔」的。每个阶段都是一个独立的函数或类,可以单独测试、单独替换。
class DataPipeline:
def __init__(self, config):
self.reader = FileReader(config['data_path'])
self.decoder = ImageDecoder()
self.cleaner = DataCleaner()
self.augmenter = AugmentationPipeline(config['aug'])
self.batcher = BatchAssembler(batch_size=config['batch_size'])
def process(self, file_list):
for f in file_list:
raw = self.reader(f) # 读文件
decoded = self.decoder(raw) # 解码
cleaned = self.cleaner(decoded) # 清洗
augmented = self.augmenter(cleaned) # 增强
yield augmented
def __call__(self, file_list):
return self.process(file_list)
这样做的好处是:
- 可调试: 哪个阶段出问题,直接定位到那个模块。
- 可复用: 换数据集时,只需要替换 Reader 和 Decoder。
- 可并行: 每个阶段都可以独立做多进程/多线程优化。
知识结构图:数据预处理与加载全流程
下面这张图,把整个数据预处理和加载的流程串起来了。你可以把它当作一个检查清单,看看自己的 Pipeline 在哪一步还有优化空间。
总结一下
数据预处理和加载,看着琐碎,但直接影响训练效率。我个人的经验是:
- 先做数据清洗,别让脏数据浪费算力。
- 数据增强要符合语义,别乱来。
- DataLoader 的 num_workers 和 pin_memory 一定要调。
- 数据太大就用内存映射,配合 SSD 效果更好。
- Pipeline 设计成模块化,方便调试和复用。