先说一个我这些年反复撞过墙的结论:很多项目最后不是死在模型上,而是死在数据上。你高高兴兴从网上下载一份公开数据集,或者从业务系统里导出一批 "新鲜" 数据,什么清洗都不做就直接塞进模型,第一轮跑完效果差得离谱,然后开始调参、换网络结构、加正则化,折腾到半夜,最后猛然回头看一眼数据——缺失值占了三分之一,类别严重不平衡,有几张图像甚至根本就是损坏文件。这种事我在不同团队里见得太多了。
所以这篇东西我不打算讲高深理论,就实打实聊聊数据集预处理这件事。从表格数据、文本数据、图像数据,到时序类和专业领域数据集(像 KITTI、MNIST、DEAP、WM-811K、FastMRI 这些被问得比较多的公开数据集),我把平时最常用的那套处理流程、代码片段和踩过的坑一起整理出来。不管你是刚入门准备跑通 YOLO 训练自己的数据集,还是已经在做语言模型相关的文档加载与切片,这篇应该都能给你一些可以直接拿去用的东西。
1. 数据预处理到底在解决什么问题
1.1 模型的上限由数据质量决定
很多人以为模型效果不好就是模型不行,实际上数据决定了模型效果的上限,模型只是去逼近这个上限。一个 200 层的 Transformer,喂进去的是充满缺失值、单位不一致、标注互相矛盾的原始数据,它依然只会学到一堆垃圾规律。这跟做菜一个道理:洗菜、切菜、配菜这些准备工作没人看得见,但食材本身不处理好,再好的大厨也没法做出像样的菜。
预处理的核心目标可以拆成四点:统一格式,让不同来源的数据能被同一个模型读取;清理异常,把缺失值、重复样本、明显错误的记录处理掉;调整分布,做标准化、归一化或类别平衡,让模型训练更稳定;特征提取,从原始数据中整理出更有表达力的信息。这四个目标不是流水线式地执行一遍就完事,而是要基于对数据本身的理解反复调整。
1.2 不同数据类型的预处理目标差异
关键在于,不同类型的数据,"干净" 的定义完全不同。表格数据里的缺失值可能是某一行找不到记录;文本数据里的噪声可能是 HTML 标签、全角空格、乱码符号;图像数据里的问题可能是尺寸不一致、通道数不同、标注坐标出界;时间序列里可能会遇到传感器掉线导致的长时间空白。用一个统一的 "数据清洗脚本" 去处理所有数据,是新手最容易犯的错误。
所以这篇文章接下来的结构就按数据类型展开,每一种我都会直接给可落地的代码和判断依据。
2. 表格数据的清洗、补全与标准化:从一张 CSV 到模型能用的输入
2.1 读文件阶段就要踩住的坑
表格数据最常见的载体就是 CSV,但很多人第一行pd.read_csv("data.csv")就翻车了。中文环境下最容易遇到的是编码问题:有的文件是 UTF-8 带 BOM,有的是 GBK,还有极端的 GB18030,用 pandas 读出来直接乱码。我现在已经习惯在读取时先试编码,或者直接统一转成 UTF-8。
读文件的几个参数值得注意,别偷懒:
import pandas as pd df = pd.read_csv( "data.csv", encoding="utf-8-sig", # 带 BOM 的 UTF-8 也能读 sep=",", # 有些导出文件用分号或制表符 na_values=["N/A", "null", "--", ""], nrows=200, # 先读前 200 行看看长什么样 )na_values这一步很多人会忽略。原始数据里可能是用"-"表示缺失,如果你不告诉 pandas,它就把"-"当成一个合法字符串。我建议一开始就维护一张缺失占位符清单,避免后续统计缺失值的时候漏掉。
文件读进来以后,先别急着处理。用df.info()看每列类型,用df.describe()看数值分布,用df.head(10).T直接扫一眼内容。这些操作花不了半分钟,但能让你在动手处理前对数据有一个整体概念。
2.2 缺失值、重复值与异常值的处理顺序和选择
我处理表格数据通常按这个顺序:先看缺失,再去重,最后处理异常。
缺失值的处理没有银弹,完全取决于业务场景和后续模型。如果这一列 80% 都是缺失,我的建议是直接丢弃,因为靠 20% 的有效值去脑补缺失部分,大概率只会引入噪声。如果是少量缺失,数值型列可以用中位数或均值填充,类别型列可以用众数填充。如果数据有时序属性(比如传感器数据),优先用df.interpolate()做线性插值,这样可以保留趋势。
这里要特别强调一点:填充缺失值用的统计量,只能在训练集上计算,不能让测试集参与进来。这个问题我放到后面"数据泄漏"部分细说,它是整个预处理里最隐蔽的坑。
重复值也要分情况。完全重复的行直接drop_duplicates()删掉,但如果是主键相同、其他字段不同的 "半重复" 数据,你需要自己定规则,比如保留离当前时间最近的一条,或者保留信息最完整的一条。盲目丢弃可能把正常业务记录也删了。
异常值检测在表格数据里最常用的是 z-score 和 IQR。z-score 是把数据标准化后看哪些点偏离均值超过 3 个标准差,适合近似正态分布的数据;IQR 则更稳健,用四分位数来界定正常范围:
Q1 = df["score"].quantile(0.25) Q3 = df["score"].quantile(0.75) IQR = Q3 - Q1 lower = Q1 - 1.5 * IQR upper = Q3 + 1.5 * IQR outliers = df[(df["score"] < lower) | (df["score"] > upper)]但检出异常值后,不要急着删。异常值有可能对应的是真实的业务风险事件,比如某个极端天气下的发电量异常,这反而是模型应该学习的信息。我一般的做法是:先看异常样本的原始记录,能合理解释的删掉,解释不了的就保留,让模型自己学。
2.3 编码、标准化与数据划分的正确姿势
表格里的类别特征不能直接喂给线性模型和神经网络,需要做编码。如果类别本身有次序关系,比如低/中/高,用OrdinalEncoder;如果没有顺序,用OneHotEncoder。用 pandas 的get_dummies也能做 one-hot,但 sklearn 版本的编码器可以保存下来,推理时能保证训练和预测的一致性,这一段我强烈建议用 sklearn。
数值特征的标准化也很关键。StandardScaler(减均值除标准差)适合多数模型,尤其是梯度下降类模型,它把特征变成均值为 0、方差为 1 的分布;MinMaxScaler(缩放到 [0,1])适合已知上下界的数据,比如图像像素值。选哪个取决于数据的分布和模型的需求,没有绝对的对错,但我个人在深度学习任务里更喜欢MinMaxScaler,因为输出范围确定,配合 sigmoid 系输出时梯度更稳定。
到了划分数据集这一步,分类任务一定记得用stratify参数保持类别比例:
from sklearn.model_selection import train_test_split X_train, X_val, y_train, y_val = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 )这个stratify=y很重要。不做分层抽样,极端情况下测试集里可能连少数类都抽不到,后面的指标评估会失真。
3. 文本数据加载、清洗与切片的完整规则:从 txt/word 到语言模型
3.1 txt 与 word 文档的加载方式
最近做语言模型相关任务的朋友问得最多的问题有两个:txt 文件、word 文件按照什么规则加载预处理,以及怎么对文档切片。先说加载。
txt 文件最直接,但编码依然是个老问题。Windows 上常见 GBK,Linux 上常见 UTF-8,一个稳妥的做法是用chardet或charset_normalizer先检测编码再读取。对于特别大的文件,不要一次性read()到内存,用for line in f逐行读,处理完了再写回新文件。
word 文件分两种。.docx用python-docx读取,遍历document.paragraphs就能拿到段落文本;.doc这种老格式没有稳定的纯 Python 方案,我自己的做法是先用 LibreOffice 批量转成.docx,再读:
libreoffice --headless --convert-to docx *.doc这个命令行在 Linux 和 Windows 上都能跑,适合批量处理 PPT、doc 等老 Office 文件。转换这一步会丢失一部分复杂排版,但对文本预处理来说通常无所谓。如果文档还包含表格里的内容,python-docx默认的paragraphs是拿不到的,需要额外遍历document.tables。
3.2 文本清洗的常用操作清单
文本清洗在 NLP 任务里是个无底洞,因为噪声类型实在太多。我用的最多的操作就这几类,大家可以直接对照检查自己的文本:
- 去掉 HTML 标签:
re.sub(r'<[^>]+>', '', text) - 去掉控制字符:
re.sub(r'[\x00-\x1f\x7f]', '', text),比如那些不可见的零宽空格 - 统一空白:把多个空格、换行、制表符合并成一个空格
- 全角转半角:中文全角符号有时会漏进来,统一转半角可以避免分词和匹配时出问题
- 清理 BOM 和零宽字符:这类字符不会显示,但会真实影响模型训练时的 token 编码
这里要提醒一句:不要一上来就删停用词。如果任务是语言模型预训练或文本生成,删停用词会破坏句子的自然结构,模型学不到真实的语言分布;如果是文本分类且词表有限,去掉的、了、是这类高频停用词可能反而有效。清洗规则必须跟着任务走。
3.3 文档切片规则与重叠窗口设计
文档切片是准备语言模型训练数据时最受关注的一步。很多人直接text[:512]、text[512:1024]这样硬切,结果句子被拦腰截断,模型看到的语义非常破碎。
我实践下来比较稳的方案是:先分句,再把句子组装成块,同时保留重叠窗口。分句可以用正则按句号、问号、感叹号切分,中文要同时兼容英文标点。组装的时候,每个块设一个最大长度,超过就另开新块,并且让新块的开头带上上一块的尾部内容,这样前后文信息不会完全切断。代码大致长这样:
import re from typing import List def clean_text(text: str) -> str: text = re.sub(r'<[^>]+>', '', text) text = re.sub(r'[\x00-\x1f\x7f]', '', text) # 控制字符 text = re.sub(r'\s+', ' ', text) return text.strip() def split_sentences(text: str) -> List[str]: parts = re.split(r'(?<=[。!?!?])\s*', text) return [p for p in parts if p] def build_chunks(sentences: List[str], max_chars: int = 500, overlap: int = 50) -> List[str]: chunks = [] cur = "" for sent in sentences: # 如果句子本身超过上限,按字符硬切 while len(sent) > max_chars: if cur: chunks.append(cur) cur = "" chunks.append(sent[:max_chars]) sent = sent[max_chars:] if cur and len(cur) + len(sent) > max_chars: chunks.append(cur) cur = cur[-overlap:] + sent else: cur += sent if cur: chunks.append(cur) return chunks在真实语言模型项目里,我会用 tokenizer 统计 token 数而不是字符数,因为 500 个字符对应的 token 数在中文和英文里差异很大。把len(sent)换成len(tokenizer.encode(sent))即可,逻辑不变。切片后的每个 chunk 就相当于一个独立训练样本,喂给模型之前还要经过 tokenizer 加上特殊标记和注意力掩码。
另外,如果文本本身有结构(比如 Markdown 标题、章节序号),最好先在结构边界处切片,再按最大长度做二次合并,这样能最大限度保留文档原有的语义段落。纯靠滑动窗口硬写的切法只适合没有明显结构的纯文本。
4. 图像数据集的目录组织、标注格式与增强:从 MNIST 到 YOLO 自己的数据集
4.1 分类任务:目录结构、缩放到归一化
图像预处理里最简单的就是分类任务。MNIST 这种灰度手写数字数据集,处理流程通常是:读入 28x28 灰度图,把像素值从[0, 255]缩放到[0, 1]或标准化到均值为 0、方差为 1,然后转成模型要求的张量格式。
如果你在用 PyTorch 做自定义图片分类,目录结构可以直接按下面这样组织,然后交给torchvision.datasets.ImageFolder读取:
data/ train/ class_a/ img_001.jpg img_002.jpg class_b/ ... val/ class_a/ class_b/注意ImageFolder按子目录名自动生成类别标签,所以目录名不要用中文和特殊字符,避免读取时出乱码。
训练前一定要看一下所有图片的尺寸和通道数。有的图像是灰度图,有的是 RGB,还有 PNG 带透明通道,如果你不统一处理,模型输入就可能遇到形状不一致的报错。稳妥做法是在数据加载器里统一Resize和ToTensor,或者预处理时全部转成同一尺寸的 RGB 图。
4.2 目标检测与分割:标注格式的统一与转换
目标检测的数据预处理比分类复杂一个量级,因为不仅图像要处理,标注框也要跟着处理。现在主流数据集和框架的标注格式五花八门:COCO 用 JSON,VOC 用 XML,YOLO 用 txt,DOTA 用四点坐标,KITTI 又有自己的一套。很多项目大部分时间都花在格式转换上。
YOLO 训练自己的数据集,我最常用的标注格式是 YOLO txt:每一行对应一个目标,内容是class_id x_center y_center width height,其中坐标都是相对图像宽高的比值,范围在0~1之间。例如0 0.5 0.5 0.2 0.3表示类别为 0,目标中心在图片中心,宽度占图片的 20%,高度占 30%。
用 labelimg 或 x-anylabeling 标注完,通常会自动生成 YOLO 格式的 txt。我每次拿到标注文件,第一件事是写一个脚本检查所有坐标是否越界,比如出现负数或者宽度超过图片宽度。这类问题在手工标注时非常常见,在 yaml 配置里修改图片尺寸之前必须先修正。
COCO 格式则完全不同,它的 bbox 是[x, y, width, height],x 和 y 是左上角像素坐标,不是归一化值,所以如果要做转换,需要除以图片宽高。如果训练的数据集是目标旋转检测(比如 DOTA 分类下的遥感飞机、船舶),标注是四点坐标,得转换为旋转框表示,这就要用 MMRotate 提供的 DOTA 转脚本。每次转换后我都建议做一次反向可视化,把标注框画在图上,确认转换没有出偏差。
4.3 数据增强的同步问题与边界条件
数据增强是图像任务里不可或缺的一步,但它的核心难点不是用什么增强,而是怎么保证增强后的标注也跟着同步变化。
分类任务比较简单,翻转、裁切、颜色抖动随便上,因为有torchvision.transforms直接处理张量即可。检测任务就不一样了,你随机翻转了一张图,图上目标的中心 x 坐标会变成1 - x,如果标注没有同步更新,模型会在训练时和标签"打架"。
我的建议是:检测任务直接使用albumentations库,它能帮你同步处理图像和 bbox/掩码。它的Compose里有一个bbox_params参数,用来定义标注的格式是pascal_voc还是yolo,它会自动完成坐标同步变换:
import albumentations as A transform = A.Compose([ A.RandomResizedCrop(width=640, height=640, scale=(0.7, 1.0)), A.HorizontalFlip(p=0.5), A.ColorJitter(brightness=0.2, contrast=0.2, p=0.5), ], bbox_params=A.BboxParams(format="yolo", label_fields=["class_labels"]))还要注意增强的参数不能太过分。我在一个医学图像分割项目里试过加入大量旋转和弹性形变,结果模型在真实数据上的效果反而更差,因为正常扫描的图像根本不会出现那种形变。数据增强的目的是模拟真实分布的变化,不是制造数据集里没有的分布。
4.4 用 YOLO 训练自己数据集时,我建议的预处理顺序
如果你是从零开始准备自己的 YOLO 数据集,我建议按下面这个顺序走一遍,能少踩很多坑:
- 收集图片,统一格式和命名,避免中文路径和特殊字符。
- 标注工具任选,导出 YOLO 格式。
- 按场景划分
train/val,分类任务可以随机分,检测任务尽量保证同一场景的图不分散到两个集合里。 - 写
data.yaml,指定train、val路径、类别数量和类别名。 - 写一个简单的检查脚本,统计每张图的 bbox 数量、类别分布,画出几张抽查图确认标注和图像对齐。
- 确认没有任何越界框、空标注图,再开始训练。
空标注图这个坑尤其隐蔽。一张图里如果没有任何目标,YOLO 的 txt 就是一个空文件,很多数据加载器读到空文件会直接报错或者静默跳过。我在训练初期排查了很久才发现是这个原因。
医学影像和遥感影像里面还有几个额外的问题。CT 图像是 HU 值,需要设置窗宽窗位再归一化,比如腹部常用的窗中心 40、窗宽 400,把[center - width/2, center + width/2]范围内的值截断再映射到0~1。遥感影像通常是超大图,不能直接喂给模型,需要滑窗切块,而且注意标注框要跟着窗口一起切。息肉分割、水下管道裂缝这类分割项目,背景像素常常占了 95% 以上,预处理时要考虑类别权重或者采样策略,不然模型的 loss 会被大背景区域彻底主导。
5. 时间序列与专业场景数据的特殊处理:光伏、风电、生理信号与医学影像
5.1 时间序列的切片与防泄漏
光伏、风力发电、卫星信号信噪比这类数据,本质上都是带时间戳的连续测量值。预处理和表格数据最大的区别在于:不能随机打乱样本,因为样本之间存在严格的时间顺序,模型需要学习的是时间依赖关系。
时间序列项目里我一般会做这几件事:先去缺失,传感器掉线的常见;缺失不多就直接线性插值,缺失成片的要考虑用前后同期的历史均值填充;再重采样到固定频率,比如把采集间隔不统一的原始记录重采样成 15 分钟一个点;最后做滑动窗口切样本,用过去一段时间预测未来一段时间。
平滑和去趋势也是常规操作。风力发电数据常常有强烈的日周期和季节周期,有时候简单的移动平均就能把噪声压下去,但做这些操作时千万注意:如果后续用深度学习模型,不要在训练前就对全量数据做滤波或归一化,否则验证集的信息会被 "过滤" 出来。所有统计量的计算都必须在训练集上完成,验证集和测试集只能被 transform。
5.2 专业领域数据集的常见特殊要求
不同专业领域的数据集有各自的脾性,有些看起来像图像数据,但处理方式完全不同。
DEAP数据集存放的是脑电和生理信号,s01.mat这类 mat 文件里,每个 trial 是一段 60 秒、128Hz 采样的多通道信号。预处理时要做通道选择、去除眼电伪迹、基线校正和滤波(尤其是 4~45Hz 频段),然后降采样或分段提取特征。这里的核心是理解生理信号的意义,不是套一个通用滤波器就能完事。
WM-811K是晶圆图数据,每个样本是一张二值图,表示同一片晶圆上哪些芯片合格哪些不合格。它的缺失值很特殊,晶圆边缘本来就没有芯片,这些位置并不是 "数据缺失",而是真实的空间结构。如果按一般表格数据的缺失值处理逻辑去填充,会直接破坏空间模式,模型就没法学到正确的失效模式了。
FastMRI这类医学成像数据,很多是把原始 k-space 数据直接给出来,需要先从 k-space 重建图像。k-space 里存的是复数值,常见预处理是裁到中心区域(比如320x320),然后把复数拆成实部虚部两个通道,或者转成幅度和相位图。这里不能像自然图像那样直接除以 255,因为数据范围完全不一样。
DeepMIMO是无线通信领域常用的信道数据集,存的是 MIMO 信道矩阵,同样涉及复数处理。读取.mat文件后,需要把矩阵 reshape 成特定维度,把实部和虚部分开,再做一个 min-max 归一化。不同场景下的数据分布差异大,建议每个场景单独计算归一化参数,而不是合并计算。
做这类专业领域任务,最重要的建议就一句话:先去读数据集的说明文档,搞清楚每个维度、每个值、每个缺失符号的实际物理意义,然后针对性设计预处理流程。不要拿一个通用脚本就去处理所有 mat 文件。
6. 预处理中的常见翻车现场与我的避坑习惯
6.1 数据泄漏:最隐蔽也最致命
数据泄漏在所有预处理坑里排第一。最典型的例子就是:先对整个数据集做标准化,然后再切训练集和测试集。这时测试集的均值、方差已经参与过训练数据的调整,测试集就不再是全新的数据,最终评估指标会虚高,真实上线时马上现原形。
正确的做法是先切分,再在训练集上 fit,然后在训练集、验证集、测试集上分别 transform。这一点在表格数据、时间序列和专业领域数据里都适用。时间序列里还要注意:不能随机拆分训练集和验证集,应该按时间顺序切分,保证验证集的时间戳永远晚于训练集,否则模型可能在验证集上偷看到了 "未来"。
6.2 文件与编码问题:读不进来就谈不上预处理
文件读不进来的情况比想象中多得多。图片路径里有中文或空格,cv2.imread会返回None;CSV 文件里有隐藏的\ufeffBOM 字符,列名的第一个列会多出一个看不见的字符;文本文件是 GBK 编码却按 UTF-8 读,直接乱码;还有那些从 Excel 导出的数据,某列混入了一个过长数字,被 pandans 当成 float,精度丢失。
我的对策是:每个新数据集开始处理前,先写一个环境校验脚本,把所有图片路径和标注路径遍历一遍,确认文件存在、格式正确、编码正确。这个脚本本身很简单,却能省下后面排查问题的一整晚。
6.3 可复现性:把预处理固化成脚本
最后一个习惯建议:所有预处理步骤都写成脚本,参数放在配置文件里,不要依赖 notebook 里手动按顺序执行的 cell。因为一旦换机器、换同事、隔几天再跑,结果可能不一样,也可能完全跑不出同样效果。我会把每次处理后的数据版本另存一份,比如data_processed_v1.parquet,并记录每一步用了什么参数。这样模型出了问题,还能回溯排查是不是预处理环节造成的。
此外,随机种子一定要固定,random.seed(42)、np.random.seed(42)、torch.manual_seed(42)一个都别落下。用于数据划分、K折交叉验证、数据增强的随机操作,都要保证可复现,这是所有严谨实验的底线。
最后再分享一个小技巧:每次做完预处理,我习惯随机挑几条样本,把处理后的结果打印出来或者可视化展示一下。图像就画 bounding box,文本就打印切片后的 chunk,表格就打印统计摘要。这一步只需要一分钟,但能让你立刻发现处理过程中有没有出错。毕竟预处理做得对不对,别等到模型训练完再来后悔。