数据预处理与加载优化:别让数据成为训练的瓶颈

说实话,很多同学把精力都花在调模型结构、调学习率上,结果训练一跑起来,GPU 利用率才 30%。我一看,好家伙,数据加载那块堵死了。数据预处理和加载,说白了就是给模型喂饭的。饭没准备好,或者喂得太慢,再好的厨师也白搭。

这一章,我们就聊聊怎么把「做饭」和「喂饭」这两个环节做到极致。

数据清洗与增强策略:喂给模型的东西要干净,还要花样多

数据清洗,听起来很基础,但坑最多。我见过有人直接把带 NaN 的数据丢给模型训练,Loss 直接飞了。也见过有人把标签搞反了,模型训了三天,准确率死活上不去。

警告: 数据清洗是「一票否决」的环节。脏数据进去,垃圾模型出来。别指望模型自己能「悟」出正确的数据分布。

常见的数据脏问题与处理

问题类型 典型表现 我的处理习惯
缺失值 NaN, None, 空字符串 数值型用中位数填充,类别型用众数或单独标记
异常值 超出 3σ 或 IQR 范围 先可视化确认,再决定截断或剔除
重复样本 完全相同的行 直接去重,除非是时序数据
标签噪声 标注错误、标签不平衡 交叉验证检查,必要时做标签清洗

数据增强就不一样了。它是主动给数据「加料」,让模型见过更多样的情况。尤其在图像领域,增强几乎是标配。

技巧: 增强不是越多越好。我见过有人对一张猫图做了 90 度旋转,结果猫变成倒立了,标签还是「猫」。模型学了半天,以为倒立的就是猫。增强要符合数据的「语义不变性」。

常用的增强策略,我列个清单:

  • 图像: 随机裁剪、翻转、色彩抖动、高斯噪声、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。设大一点可以掩盖加载延迟,但会多占内存。
经验之谈: 我曾经在一个 8 卡训练任务里,发现 GPU 利用率只有 40%。排查了半天,发现是 num_workers=0,所有数据加载都在主进程里串行执行。改成 num_workers=8 后,利用率直接飙到 95% 以上。有时候性能问题就这么简单。

内存映射与磁盘 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 优化,核心就两点:

  1. 顺序读写优于随机读写。 把数据打包成 TFRecord、HDF5 或 MMap 格式,让磁盘磁头少来回移动。
  2. 使用 SSD 代替 HDD。 这个不用多说,SSD 的随机 I/O 性能比 HDD 高两个数量级。
注意: 内存映射不是银弹。如果你的数据访问模式是「全量随机」,而且内存足够大,还是直接加载到内存更快。内存映射适合「数据大、内存小、访问局部性强」的场景。

数据预处理 Pipeline 设计:把流程串起来,还要能调试

一个完整的数据预处理 Pipeline,通常包含这几个阶段:

  1. 原始数据读取: 从磁盘或网络读取原始文件
  2. 解码与解析: 图片解码、JSON/CSV 解析
  3. 清洗与过滤: 去重、去异常、填充缺失
  4. 增强与变换: 数据增强、归一化、Tokenization
  5. 批处理与打包: 组装成 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 里加一个「缓存层」。比如把解码后的图片缓存到内存或本地磁盘,下次训练时直接读缓存,省掉重复解码的时间。我有个项目,加了缓存后,训练速度提升了 3 倍。

知识结构图:数据预处理与加载全流程

下面这张图,把整个数据预处理和加载的流程串起来了。你可以把它当作一个检查清单,看看自己的 Pipeline 在哪一步还有优化空间。

数据预处理与加载优化全流程 数据预处理与加载优化全流程 原始数据 磁盘 / 网络 / 数据库 解码与解析 图片解码 / JSON 解析 清洗与过滤 去重 / 去异常 / 填充 增强与变换 数据增强 / 归一化 批处理与打包 组装 Batch / 送 GPU 内存映射 & I/O 优化 MMap / SSD / 顺序读写 核心优化目标:减少 GPU 等待时间,提高数据吞吐量 Pipeline 可插拔设计 → 每个阶段独立测试、独立优化

总结一下

数据预处理和加载,看着琐碎,但直接影响训练效率。我个人的经验是:

  • 先做数据清洗,别让脏数据浪费算力。
  • 数据增强要符合语义,别乱来。
  • DataLoader 的 num_workers 和 pin_memory 一定要调。
  • 数据太大就用内存映射,配合 SSD 效果更好。
  • Pipeline 设计成模块化,方便调试和复用。