深度学习数据预处理实战:从清洗、编码到PyTorch DataLoader
我最早跟着某系列网课学深度学习的时候老师第一句话不是讲网络结构而是说一个模型80%的精力都花在数据处理上。当时我还不信觉得不就是把文件读进来吗。直到自己动手做第一个比赛项目断断续续写了三个晚上的清洗和特征转换代码才真正理解这句话的份量。数据预处理这堂课名义上是第三课但在我心里其实是真正分水岭的一课——前面学的张量操作、自动求导都还停留在工具层面数据预处理才是第一次把问题域和模型连接起来。这篇我就把那节课的核心内容拆开按实际操作路径重新过一遍包括我后来在几个模拟项目里踩过的坑一并写出来。1. 先别急着写代码模型要吃什么先得想清楚很多人打开 Jupyter 的第一步就是pd.read_csv()恨不得立刻把数据塞进模型里。但在动手之前其实有一件更基础的事值得花十分钟做搞清楚手头的数据长什么样以及最终要以什么形态进入网络。1.1 数据在训练流程里到底处于什么位置一个标准的深度学习训练流程大致是原始文件 → 加载解析 → 清洗修正 → 特征变换 → 切分数据集 → 封装为可迭代的数据供给器 → 进入模型。数据预处理覆盖了从原始文件到封装为数据供给器之间的所有环节。这个流程看起来很线性但在实际项目中往往是反复迭代的。你可能训练完一轮发现某个特征分布太诡异于是回过头重新处理也可能在验证集上发现结果虚高最后定位到预处理时不小心混入了未来信息这种低级错误。所以数据预处理不是一次性工作它的质量标准是可复现、可解释、不泄漏。1.2 先给数据分分类表格、图像、文本的预处理重心完全不同我在带 A 同学、B 同学做模拟项目时发现他们常犯同一个错误用处理表格数据的思路去处理图像或者反过来。表格型数据如 CSV、数据库表重心在缺失值、重复值、异常值、编码和标准化。图像数据重心在解码、缩放、归一化到固定范围、数据增强随机裁剪、翻转、色彩抖动。文本数据重心在分词、去除停用词、构建词表、padding/truncation 到定长。时序数据重心在时间窗口切分、排序、防止未来信息泄漏。这篇文章主要讲表格型数据这是深度学习入门阶段最常见的载体也是后面图像和文本预处理的基础。理解了表格数据的一套流程再去看图像增强、文本分词会发现底层思路是一致的让原始数据变成模型能消化的数值张量。1.3 一个容易忽略的起点检查数据形态与统计摘要打开数据集之后我建议先跑几个最基础的命令不要跳过import pandas as pd df pd.read_csv(raw_data.csv) # 看一眼形状和字段 print(df.shape) print(df.columns.tolist()) # 粗略看数据类型和非空数量 print(df.info()) # 数值列的统计摘要 print(df.describe()) # 前5行形成直觉 print(df.head())这几个命令能在几分钟内告诉你数据有没有缺失、有没有完全没用的列、数值列的量纲差异大不大、有没有明显的离群值。很多问题在这一步就能提前发现而不是等到模型跑出离谱的 loss 再回头排查。2. 数据清洗三板斧缺失值、重复值、异常值的实际取舍数据清洗是整个预处理里最脏的活也是网上教程最容易一笔带过的部分。教程里通常只丢出一句用 dropna 删除缺失行但真实数据里的情况远比这复杂。这里我把三板斧一个一个拆开讲。2.1 缺失值不是所有没有都该被填充缺失值的处理方式只有有限的几种删除行、删除列、填充、标记缺失。难点在于选择。我自己的经验是分成三步来判断。第一步看缺失比例。如果某个字段缺失超过 70%基本可以直接放弃这一列。这种列就算强行填充注入的也是大量人为噪声对模型帮助不大。如果缺失比例很低比如 1% 到 5%并且行数本身很大那直接删除这些行通常也说得过去。第二步看缺失的机制。如果数据缺失是完全随机发生的比如用户在某一步忘填了一个选填项那删除或者均值填充都行。但如果缺失和某个行为模式有关——比如发高烧的人不愿意记录体温那缺失本身就是有信息量的最好增加一列是否缺失的标记再配合填充。第三步选填充策略。数值型字段最常用的是均值、中位数填充偏态严重的分布用中位数更稳时序数据推荐用前向填充也就是拿上一时刻的值补空缺。类别型字段通常用众数填充或者单独填一个 Unknown 类别。# 数值列按训练集的中位数填充 medians train_df.median(numeric_onlyTrue) train_df.fillna(medians, inplaceTrue) valid_df.fillna(medians, inplaceTrue) # 类别列用 Unknown 标记 categorical_cols train_df.select_dtypes(include[object]).columns.tolist() train_df[categorical_cols] train_df[categorical_cols].fillna(Unknown)注意这里有个细节填充用的中位数、均值必须只用训练集计算然后作用到验证集和测试集上。这就好比模拟考试划的复习范围不能提前把答案告诉考生。后面我会专门展开讲这个坑。2.2 重复值有些重复是噪音有些重复是信号df.drop_duplicates()几乎出现在每一份数据清洗代码里但我见过不止一次因为盲目删除重复值而把事情搞砸的情况。一个分类任务的数据集目标是判断一条交易记录是否异常每条记录都是某个时刻的真实事件。这个时候如果模型不懂业务背景直接把特征一模一样的记录当成重复项删掉可能恰恰删掉了一组高价值的异常样本——异常在现实中本来就容易成群出现。所以删除重复值之前先问自己一句这条记录里的重复到底是采样错误还是真实世界的重演如果确认是录入错误、重复采样的脏数据那么drop_duplicates()可以放心用。但更稳妥的做法是先按全字段去重看一遍再按关键字段去重看一遍观察两次数量差异。如果差异非常大就要警惕同一用户多次合法操作被当成重复的情况。2.3 异常值3σ 只是参考线不是金科玉律数值型特征里的极端值会严重干扰标准化和梯度更新所以异常值检测几乎是必做的。教科书里通常说超过均值 ±3 个标准差就视为异常但在真实分布里如果数据本来就偏态严重均值和标准差都会被极端值本身带跑3σ 这个标准就不太可靠了。我更推荐用分位数法也就是 IQR四分位距法Q1 df[feature].quantile(0.25) Q3 df[feature].quantile(0.75) IQR Q3 - Q1 lower Q1 - 1.5 * IQR upper Q3 1.5 * IQR outliers df[(df[feature] lower) | (df[feature] upper)]这个方法的好处是对偏态分布更鲁棒因为四分位数不容易被极端值污染。但真正重要的是最后一步把检测出来的异常值列出来结合实际业务场景逐条看。不要见到异常就删有些异常值恰恰是模型要预测的目标信号。比如预测点击率时极端高的点击量可能就是明星内容属于关键特征。处理异常值的常用手段也不只是删除还有截尾处理Winsorize把超出上下界的数据强行压到边界值这在深度学习里有时比直接删除更友好因为它保留了样本量。3. 数值特征的两条转换路线标准化与归一化的选择逻辑数据清洗干净之后下一个绕不开的问题是数值列的尺度差异巨大怎么办。比如一列身高范围是 150 到 190另一列收入范围是 3 千到 5 万直接喂给模型会把梯度计算搞得乱七八糟。这时候就要做特征缩放。3.1 Z-Score 标准化为什么是深度学习的默认选项Z-Score 标准化的公式很简单x (x - mean) / std。处理之后数据分布以 0 为中心方差为 1。为什么深度学习更常用它因为神经网络里的激活函数比如 ReLU、Tanh和数据分布密切相关。输入数据如果集中在某个偏移很大的区间初始化权重和激活值很容易不匹配导致收敛很慢。Z-Score 让每个特征处于相近的尺度区间优化过程平稳很多。另外计算距离的模型比如带 L2 正则的模型对特征的绝对尺度极度敏感。一个 0 到 10000 的特征会天然支配欧氏距离的计算L2 惩罚对它的作用也会被放大。Z-Score 之后所有特征对距离的贡献才变得公平。from sklearn.preprocessing import StandardScaler scaler StandardScaler() # 这里只 fit 训练数据再 transform 所有数据 train_scaled scaler.fit_transform(X_train) valid_scaled scaler.transform(X_valid) test_scaled scaler.transform(X_test) # 保存 scaler推理阶段还要用 import joblib joblib.dump(scaler, scaler.save)这里的关键是每一条只用训练集的均值和标准差来 fit然后动作统一 apply 到验证集和测试集。如果 fit 的时候把整个数据集都算进去验证集的信息就跟训练集串了后面评估出来的指标就不是真实水平了。3.2 Min-Max 归一化的适用场景别只在教科书里认识它Min-Max 归一化把数据压到 [0,1] 区间x (x - min) / (max - min)。它不像 Z-Score 那样能消除离群值的影响因为 min 和 max 本身就是对极端值非常敏感的统计量。但它有一个 Z-Score 无法替代的价值保正保边界。图像领域几乎不用 Z-Score而是把像素值除以 255 压到 [0,1]这是因为像素天然有明确的上下界而且神经网络里经常配合 Sigmoid 或者像素值约束使用。再比如某个特征本身有物理边界温度不可能低于 -273用 Min-Max 会保留这种边界属性。总之特征有无界分布或者分布接近高斯选 Z-Score特征有固定的有限范围或者下游模型要求输入非负区间选 Min-Max。没有绝对好坏关键看场景。3.3 数据泄漏的高发区预处理顺序与看没看测试集在讲编码和切分之前我想先插一段非常重要的提醒因为这是我在项目里见过最多人栽跟头的地方。数据泄漏用一句话概括就是模型在训练时偷看了本不该知道的信息导致训练评估虚高上线就崩。预处理阶段最容易产生泄漏的有两个动作第一个动作是用全量数据做统计量计算。无论是均值、中位数、标准差还是 min、max只要用了测试集的数据去算训练时模型就从统计量里间接接触了测试集信息。第二个动作是特征选择时看了测试集。有的人先跑一遍全量相关性分析挑出和标签相关性高的特征再切分数据集训练。这一步看起来无伤大雅但实际上全量相关性已经混入了测试集标签信息模型在测试集上的表现就不能反映真实水平了。正确的顺序永远是先切分再在训练集上 fit再把转换动作应用到其他数据集。这条铁律要刻进骨头里。4. 离散特征的编码谜题从 LabelEncoder 到 OneHot 的边界表格数据里除了数值列还有大量离散类别列比如城市、职业、等级、颜色。这类数据不能直接喂给模型必须编码成数值。这里面的坑比看起来多得多。4.1 没有顺序关系的类别独热编码是稳妥选择对于颜色、城市、门派这类没有大小关系的类别最稳妥的做法是独热编码OneHot。每个类别被拆成一个维度这个维度取 0 或 1。[\text{红色} \rightarrow [1,0,0]\quad \text{蓝色} \rightarrow [0,1,0]\quad \text{绿色} \rightarrow [0,0,1]]独热编码的好处是彻底消除了大小的含义模型不会误以为类别 2 大于类别 1。代价是维度爆炸如果某列有 100 个类别就会多出 100 个列。在 sklearn 里通常这样编码from sklearn.preprocessing import OneHotEncoder enc OneHotEncoder(handle_unknownignore, sparse_outputFalse) train_encoded enc.fit_transform(train_df[categorical_cols]) valid_encoded enc.transform(valid_df[categorical_cols])handle_unknownignore这个参数我每次都会强调。它保证当验证集或线上推理阶段出现训练时没见过的类别时不会直接抛错而是编码成全零向量。真实数据里新类别频繁出现不提前设置这个参数会死得很惨。4.2 有顺序关系的类别标签直接用整数顺序有些类别有天然的顺序比如教育程度小学 中学 本科 研究生、会员等级、满意度评分差 一般 好。对这些字段做独热编码反而会丢掉顺序信息直接用保序的整数映射更合理order_map { 小学: 0, 中学: 1, 本科: 2, 研究生: 3, } df[education_ordered] df[education].map(order_map)这样编码之后模型可以学到3 比 1 代表更高层级这种连续趋势。需要注意的是保序映射不等于让模型默认间隔相等中间要不要做进一步缩放可以结合树模型或线性模型的特点再决定。不过对深度学习来说整数顺序通常比独热编码更省维度也不容易踩稀疏矩阵的坑。4.3 高基数类别字段先压缩再编码如果某列有几千个类别直接 OneHot 会让特征矩阵变成巨大的稀疏矩阵不但占内存训练效率也低。处理思路是先做频数压缩统计每个类别的出现次数高频的保留低频的合并成一个 其他 类。counts df[city].value_counts() rare_categories counts[counts 10].index df[city_compressed] df[city].replace(rare_categories, 其他)压缩之后再编码维度就骤降了。这种做法的代价是低频类别之间的区别被抹平了但对深度学习来说低频类别的样本量本身就不足以支撑模型学到可靠表示合并掉往往是划算的。更进阶的方式是目标编码Target Encoding但因为容易引入标签泄漏初学者我不建议一上来就用。5. 数据集的切分训练集、验证集、测试集的比例和姿势预处理做得差不多了另一个关键决策是数据怎么切。切得不好轻则评估指标失真重则模型上线效果大跳水。5.1 比例没有银弹大批量数据和小批量数据策略完全不同教科书里常写的 8:1:1 或 7:2:1 是通用方案但真实工程里要看数据总量。数据量很小几千条以内训练集占比可以高一些留出 20% 的验证集和 20% 甚至更多测试集可能反而让训练样本不足。更推荐用交叉验证来评估模型稳定性。数据量中等几万到几十万条7:2:1 或者 8:1:1 都行注意验证集足够大才能反映真实分布。数据量很大百万级以上验证集和测试集不需要按比例堆很多各留几千到几万条就足够统计可靠了。大批量下切 1% 当验证集很正常。最关键的原则是验证集要能代表真实使用场景。如果数据收集有地域偏差验证集切的时候要尽量保留这种结构别让某类样本恰好没进验证集。5.2 分类任务一定要用分层切分stratify 参数别省略分类任务里如果标签不均衡比如 99% 是负例1% 是正例随机切分很可能让验证集里正例个数少得可怜甚至为 0。踩过一次你就会明白那种评估指标忽上忽下的痛苦。解决办法是分层抽样。sklearn 的train_test_split提供了现成参数from sklearn.model_selection import train_test_split X_train, X_temp, y_train, y_temp train_test_split( X, y, test_size0.3, random_state42, stratifyy ) X_valid, X_test, y_valid, y_test train_test_split( X_temp, y_temp, test_size0.5, random_state42, stratifyy_temp )stratifyy会让划分后的训练集、验证集、测试集里每个类别的比例尽量与原始数据一致。深度学习里标签不均衡是常态养成写stratify的习惯能省掉很多后续麻烦。回归任务没有分类那么依赖分层但也可以按分箱后的目标做近似分层这里就先不多说了。5.3 时间序列数据的切分随机打乱是大忌处理时序类数据时最常见的错误是沿用表格数据思维随手shuffleTrue。时序数据的顺序本身就是最重要的信息打乱之后训练集里会出现来自未来的样本。正确的做法是按时间顺序切分。比如用前 70% 时间段做训练随后的 15% 做验证最后 15% 做测试。如果要用滑窗构造样本还需要特别注意窗口不能跨过训练集和测试集的边界去取上下文否则依然会引入未来信息。这个点看起来不起眼但凡是做过商品销量预测、流量预测这类项目的人基本都在这里吃过亏。6. 端到端实操从原始 CSV 文件到 PyTorch DataLoader讲了这么多原则下面我以一个模拟的表格数据为例把从read_csv到DataLoader的完整流程跑一遍。假设数据集有数值列age、income类别列city、education目标列label任务是一个二分类。6.1 补全清洗与变换代码每一步都留痕、可回滚先读入数据并切分import pandas as pd from sklearn.model_selection import train_test_split df pd.read_csv(sample_data.csv) # 先切分再处理避免一切数据泄漏 train_df, temp_df train_test_split(df, test_size0.3, random_state42, stratifydf[label]) valid_df, test_df train_test_split(temp_df, test_size0.5, random_state42, stratifytemp_df[label]) print(train_df.shape, valid_df.shape, test_df.shape)然后做特征列划分num_cols [age, income] cat_cols [city, education] target_col label数值列先填充再标准化from sklearn.preprocessing import StandardScaler # 只用训练集计算中位数填充 num_medians train_df[num_cols].median() train_df[num_cols] train_df[num_cols].fillna(num_medians) valid_df[num_cols] valid_df[num_cols].fillna(num_medians) test_df[num_cols] test_df[num_cols].fillna(num_medians) scaler StandardScaler() train_df[num_cols] scaler.fit_transform(train_df[num_cols]) valid_df[num_cols] scaler.transform(valid_df[num_cols]) test_df[num_cols] scaler.transform(test_df[num_cols])类别列先压缩低频再独热from sklearn.preprocessing import OneHotEncoder # 只在训练集上统计低频类别 cat_freq train_df[city].value_counts() rare_cities cat_freq[cat_freq 10].index train_df[city] train_df[city].replace(rare_cities, 其他) # 对验证集、测试集同样替换 valid_df[city] valid_df[city].replace(rare_cities, 其他) test_df[city] test_df[city].replace(rare_cities, 其他) encoder OneHotEncoder(handle_unknownignore, sparse_outputFalse) train_cat encoder.fit_transform(train_df[cat_cols]) valid_cat encoder.transform(valid_df[cat_cols]) test_cat encoder.transform(test_df[cat_cols])注意这里对验证集、测试集做类别替换时用的是训练集统计出来的低频列表也必须保持这个严格分离。最后拼出最终特征矩阵import numpy as np X_train np.hstack([train_df[num_cols].values, train_cat]) X_valid np.hstack([valid_df[num_cols].values, valid_cat]) X_test np.hstack([test_df[num_cols].values, test_cat]) y_train train_df[target_col].values y_valid valid_df[target_col].values y_test test_df[target_col].values6.2 封装成 PyTorch 的 Dataset 和 DataLoader有了 NumPy 数组接下来就是深度学习中真正喂数据的一步。这步虽然不长但我见过不少初学者直接在训练循环里写for i in range(len(X_train))这样又慢又容易出错。正确做法是封装成 Datasetimport torch from torch.utils.data import Dataset, DataLoader class TabularDataset(Dataset): def __init__(self, X, y): self.X torch.tensor(X, dtypetorch.float32) self.y torch.tensor(y, dtypetorch.long) def __len__(self): return len(self.y) def __getitem__(self, idx): return self.X[idx], self.y[idx] train_dataset TabularDataset(X_train, y_train) valid_dataset TabularDataset(X_valid, y_valid) test_dataset TabularDataset(X_test, y_test) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) valid_loader DataLoader(valid_dataset, batch_size128, shuffleFalse) test_loader DataLoader(test_dataset, batch_size128, shuffleFalse)几个值得展开的点shuffleTrue只加在训练集验证集和测试集不要打乱因为你评估模型时不需要随机性。batch_size的选择会影响显存和收敛速度。小数据集用 16 或 32大数据集用 64、128 甚至更大。实际调参时建议从 32 起步观察显存和收敛情况再增再减。torch.tensor(X, dtypetorch.float32)中 dtype 要与模型权重类型一致。如果后面用了 GPU 半精度训练这里还需要额外转成 float16。6.3 训练循环里的随机性控制在训练时还需要在迭代器外层加一个随机种子控制。虽然前面切分数据时已经用了random_state42但 PyTorch 自身的数据加载顺序、模型初始化都带有随机性。为了让实验结果可复现通常在文件开头统一设置import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed) set_seed(42)这样做的好处是同样代码、同样种子每次跑出来的指标几乎一致。做实验对比时你改的每一点差异都能归因于代码改动而不是又随机到了好结果。7. 我在实际项目中躲过的几个数据预处理坑最后这部分完全是经验之谈。这些坑我在项目里都遇到或围观过写出来帮你省几个晚上的排查时间。7.1 坑一scale 完才发现漏了缺失值处理有一种很隐蔽的报错StandardScaler 收到 NaN。大多数教程会告诉你标准化前先填充缺失值但实际场景里填充往往发生在不同的代码块一旦你中间插入了一列新特征或者改了填充条件训练时可能不会立刻出问题——标准化过程中 Nan 会传染最终模型 loss 直接变 NaN。排查思路很简单任何一步转换函数执行之前先检查一遍当前特征矩阵里有没有 NaN。assert not np.isnan(X_train).any(), X_train contains NaN!这个断言我几乎写进了所有预处理管道。别嫌它琐碎能救命的都是这种琐碎检查。7.2 坑二DataLoader 的 shuffle 与数据顺序耦合我一度认为shuffleTrue就万事大吉了后来发现数据加载的随机顺序和数据集内部的排序规律掺杂在一起时会导致 Batch 内类别分布剧烈波动。比如原始数据按时间排列负例集中在前半段、正例集中后半段即使shuffleTrue有些 Batch 里依然可能全为负例或全为正例。解决这个问题的标准做法是在构造 Dataset 前先把数据整体打散一次或者干脆用WeightedRandomSampler做显式的类别均衡采样。对于不均衡分类任务后者比shuffle在稳定训练方面更有效。7.3 坑三独热编码列顺序漂了如果两个独热编码器分别处理两个不同的数据集代码写得不小心得到的列名顺序很可能不一致拼接出来的特征矩阵对不上号。这种情况在预处理代码被拆成多个函数、多次运行时特别容易出现。我的习惯是所有特征变换全部封装成带fit和transform方法的类训练完成之后把整个预处理管道序列化保存。推理阶段直接加载管道不要重新写一遍处理逻辑。这一步能避免的超乎你想象的线上 bug。7.4 坑四图像和文本预处理里同款陷阱虽然这篇文章以表格数据为主但图像和文本里的数据泄漏往往更隐蔽。比如图像做数据增强时如果对验证集也做了随机翻转和裁剪那验证集每次评估结果都会不一样模型调参时根本没法稳定对比。正确做法是增强只作用于训练集验证集和测试集只做固定的缩放和归一化。文本处理里类似的坑是构建词表时用了全量文本包括测试集这样模型提前看到了测试集里才出现的词等于变相泄漏了词汇分布信息。词表也必须只在训练集上构建。写在最后数据预处理这堂课跨度很大从 pandas 的基础操作到 sklearn 的编码器再到 PyTorch 的 DataLoader内容量几乎可以单独撑起一门课。但核心其实就三句话先想清楚数据形态再按清洗 → 变换 → 编码 → 切分 → 封装的顺序走过程中时刻提防数据泄漏。我自己带项目时的习惯是每次写完预处理代码先问自己三遍测试集信息有没有被用到统计量里没想清楚就绝不开训练。这套流程一开始写起来会觉得啰嗦但跑过几次端到端之后你会意识到前期这些功夫都是给后面的调试省时间的。