
1. 预处理决定了模型性能的天花板在哪同样一份数据同样一个 XGBoost两个人跑出来的 AUC 差 0.06这种情况我在实际项目里遇到过不止一次。复盘下来差异几乎从来不在调参上而在前面那几百行没人愿意写、也最不出彩的预处理代码里。模型本身是个拟合器它做的唯一一件事就是找输入特征和标签之间的映射关系输入里有什么噪声、什么量纲错位、什么被悄悄泄露的信息它照单全收。所以那句话其实说得挺实在模型的上限由数据和特征决定算法的任务只是逼近这个上限。这篇内容想聊的就是这件事——数据预处理到底在做什么、每一步背后的判断依据是什么、哪些操作看起来合理但实际上是坑。它适合刚入门机器学习、正在从跑通 demo往能出活过渡的人也适合已经写过一些项目、但总是感觉模型效果差口气却找不到原因的人。文中涉及的代码以 Python 生态为主pandas、scikit-learn 这些因为这是目前门槛最低、复用性最好的组合但里面的判断逻辑跟工具无关用 Spark、用 SQL、甚至用 Excel 手工做思路是一样的。我习惯把预处理拆成三条主线来看这个划分方式对我理清思路帮助很大清洗Cleaning处理缺失值、异常值、重复记录、格式不一致、单位混乱。目标是让数据的物理形态先正确不追求效果提升只求不引入错误。转换Transformation缩放、编码、分布变换、构造新特征。目标是让数据长得适合模型吃解决量纲、类型、分布形态的问题。降维Reduction特征选择、特征提取。目标是压缩信息密度降低维度灾难和过拟合风险同时压计算成本。这三者不是必须全做而是按数据实际情况挑选。很多人一上手就想把三样全上一遍结果把一个本来只有 12 个特征的小数据集硬生生 PCA 压到 5 维信息损失比收益大得多。判断要不要做某一步比会做这一步更重要后面每个章节我都会把什么时候该做、什么时候别做讲清楚。2. 数据清洗先让数据在物理上是对的2.1 缺失值不是填个均值就完事统计缺失率的动作很多人跳过直接进填充这是第一个坏习惯。我通常先做三件事算每一列的缺失比例、看缺失是不是随机、看缺失本身有没有信息量。第一件事最简单df.isna().mean()就够了。第二、三件事才是关键。缺失机制分三种完全随机缺失MCAR、随机缺失MAR、非随机缺失MNAR。举个具体例子一份用户行为表里上次登录时间大量为空如果这是因为新注册用户还没登录过那缺失和用户是新人强相关属于 MAR 甚至 MNAR此时盲目填充一个全局中位数等于把新老用户抹平模型反而学不到从未登录这个强信号。我的处理优先级是这样的缺失比例推荐策略适用场景 5%直接删除行或简单填充样本量大删除代价可忽略5% ~ 30%填充 缺失指示列缺失可能携带信息30% ~ 60%谨慎评估优先考虑该列是否可用需要考虑业务含义 60%通常直接丢弃该列除非业务上极其关键填充方式的选择也有讲究。数值型特征如果分布接近对称用中位数比均值更稳因为均值会被长尾拽偏如果特征之间有强相关用 KNN 或者迭代填充IterativeImputer能利用列间关系效果通常比单列统计量好。类别型特征我会单独留一个Unknown类别而不是用众数填这样模型的树分裂可以直接把未知切成一个独立分支。import pandas as pd import numpy as np # 先看缺失分布别急着填 missing df.isna().mean().sort_values(ascendingFalse) print(missing[missing 0]) # 对高缺失但可能有意义的列加缺失指示器 df[last_login_is_missing] df[last_login_days].isna().astype(int) # 数值列用中位数填充类别列单独归为 Unknown num_cols df.select_dtypes(includenp.number).columns cat_cols df.select_dtypes(includeobject).columns df[num_cols] df[num_cols].fillna(df[num_cols].median()) df[cat_cols] df[cat_cols].fillna(Unknown)注意填充用的统计量必须只从训练集计算再应用到验证集和测试集。这一点在后面讲数据泄漏时还会展开但这里先埋个钉子。2.2 异常值先分清是录入错误还是真实信号发现异常值的第一反应不应该是删而是问一句这个值有可能是真的吗我在一个能耗预测项目里遇到过某天用电量是平时的 40 倍查下来是传感器故障导致的重复上报这是错误删掉但另一个项目里某些用户的下单金额是普通用户的 50 倍查下来是团购大客户这是真实业务信号删掉反而破坏分布。识别方法上我常用的组合是箱线图 IQR 规则加 Z-Score再叠加业务规则IQR 规则[Q1 - 1.5*IQR, Q3 1.5*IQR]之外视为离群。它对分布形态没假设鲁棒性好是我默认的第一选择。Z-Score|x - mean| / std 3。适合近似正态的分布但对极端值敏感因为极端值本身会拉高 std导致判定阈值被污染。分位数截断直接按 1% 和 99% 分位做缩尾Winsorize保留记录但压住数值适合极端值确实存在但不想让它主导梯度的情况。q1, q3 df[amount].quantile([0.25, 0.75]) iqr q3 - q1 lower, upper q1 - 1.5 * iqr, q3 1.5 * iqr # 只标记不直接删先人工或规则复核 df[amount_outlier] ((df[amount] lower) | (df[amount] upper)).astype(int) # 确认是错误值后再缩尾 df[amount_clipped] df[amount].clip(lower, upper)这里有个反直觉的经验对树模型来说异常值的影响远小于对线性模型和神经网络的影响。树模型做的是阈值切分一个异常值最多让它多切一刀但线性回归和神经网络会对大数值的样本产生巨大梯度一个异常点能把整个拟合方向带歪。所以是不是要处理异常值也要看下游用什么模型。2.3 重复值与一致性问题最不显眼但最伤评估重复行不一定是完全相同的两行。真实场景里更常见的是业务主键重复但字段略有差异比如同一个订单 ID 出现两次一次金额 100、一次 100.0或者时间戳差了 2 秒。这种重复如果不处理会造成两个后果一是训练集和测试集里出现同一条记录的不同副本评估分数虚高二是类别不平衡问题被放大。排查顺序我一般这样走按业务主键去重保留最新一条或按业务规则保留;检查类别标签的一致性同一 ID 出现两个不同标签的要单独拉出来看这通常意味着标注流程有问题检查数值列的单位和精度比如有的列是元、有的是分有的金额被存成了字符串还带千分位逗号。一致性问题往往比缺失值更隐蔽。我见过一次模型在离线评估 AUC 0.86、上线后掉到 0.62最后定位到是训练集里的城市字段用的是拼音而线上数据用的是中文类别编码器对不上全被当成未知类别处理了。这类问题没有任何工具能自动帮你发现只能靠把每一步的输入输出打出来看。3. 数据转换让不同特征站在同一起跑线上3.1 标准化还是归一化取决于模型和分布这个问题的标准答案通常是看模型但我想说得更具体一点因为它其实取决于两件事模型对量纲的敏感度以及特征的分布形态。距离类与梯度类模型对量纲敏感KNN、K-Means、SVM、PCA、逻辑回归、神经网络。这些模型的损失函数或者距离计算里直接包含了特征数值一个取值范围 0 到 100000 的收入字段会把取值范围 0 到 1 的点击率字段彻底压死。树模型基本不敏感决策树、随机森林、XGBoost、LightGBM 做的是单特征阈值切分把一列乘以 100 不改变任何切分点的相对顺序所以理论上不需要缩放。再看分布形态Z-Score 标准化(x - mean) / std输出均值 0、方差 1不限定上下界。适合近似正态、或者有极端值但不想压缩它们的场景。Min-Max 归一化(x - min) / (max - min)输出严格落在 [0, 1]但最大值最小值完全由两个极端点决定一个异常值就能把其他所有样本压成一团。Robust Scaling(x - median) / IQR用中位数和四分位距代替均值和标准差对异常值鲁棒我在真实脏数据上用得最频繁。from sklearn.preprocessing import StandardScaler, MinMaxScaler, RobustScaler # 常规选择顺序先 Robust再考虑 Standard最后才 MinMax scalers { standard: StandardScaler(), minmax: MinMaxScaler(), robust: RobustScaler(), }一个实用的判断方法做完缩放后画出缩放前后的分布直方图叠加对比。如果 Min-Max 之后 95% 的样本都挤在 [0, 0.02] 这个区间里说明你被一两个极端值绑架了换 Robust 或者先做缩尾。3.2 类别编码独热、序数、目标编码各有适用边界类别特征的处理是另一个高频出错点。三种主流方式的边界很清楚独热编码One-Hot适合基数低的类别比如性别、设备类型、星期几。基数高的时候会直接炸维度——一个有 5000 个 SKU 的商品 ID 做独热就是 5000 列稀疏特征大部分树模型在这种稀疏矩阵上效率极低。经验阈值是基数的 15 到 20超过就考虑别的方案。序数编码Ordinal适用于类别之间存在真实顺序的情况比如低/中/高、教育程度。对无序类别强行用序数编码会引入虚假的大小关系线性模型会据此算出错误权重。但要注意树模型其实不太受这个影响因为树可以针对单个取值做切分不过如果类别本身无序序数编码会让树需要多次切分才能把某个类别隔离出来效率变低。目标编码Target Encoding适合高基数类别。用该类别对应的标签均值来替换类别值维度不增加还能捕捉类别与标签的关系。但它有个致命问题极易造成标签泄漏。如果直接用全量数据算均值训练集里每个样本的编码值里都包含了它自己的标签模型会学到一个虚高的相关性。正确做法是折内编码——在 K 折交叉验证的每一折里用除当前折之外的样本计算类别均值。from sklearn.preprocessing import OneHotEncoder from category_encoders import TargetEncoder from sklearn.model_selection import KFold # 独热适合低基数 ohe OneHotEncoder(handle_unknownignore, sparse_outputFalse) # 目标编码必须配合折内计算不要让编码器看到 y 的全貌 te TargetEncoder(cols[merchant_id], smoothing20)smoothing参数也值得说一句。类别样本量很少的时候均值会非常不稳定一个只有 2 条记录的类别均值可能就是极端值。平滑的作用是把类别均值往全局均值拉样本越少拉得越狠这是防止高基数目标编码过拟合的关键手段。3.3 分布变换把长尾拉回来很多真实特征天然是长尾的用户消费金额、页面停留时长、评论数、文件大小。这类特征直接喂给线性模型或神经网络会让少数极大值样本主导损失。对数变换是最省事的办法import numpy as np # 有零值用 log1p避免 log(0) df[amount_log] np.log1p(df[amount]) # 想更贴近正态可以用 Box-Cox要求正数或 Yeo-Johnson允许零和负数 from sklearn.preprocessing import PowerTransformer pt PowerTransformer(methodyeo-johnson) df[[amount_pt]] pt.fit_transform(df[[amount]])Box-Cox 和 Yeo-Johnson 会自动搜索一个最优的幂次参数 λ比手工试 log、sqrt 更系统。实测下来对偏度大于 1 的连续特征做一次幂变换线性模型的表现通常有 1 到 3 个百分点的提升。但树模型对这步几乎无感因为单调变换不改变排序树的分裂点不受影响——所以别在树模型上浪费时间做分布变换。4. 数据降维什么时候该压什么时候别碰4.1 PCA 到底在做什么以及它不做什么PCA 的数学本质是找一组新的正交坐标轴让数据投影到这些轴上的方差依次最大。第一个主成分是方差最大的方向第二个是与第一个正交的次大方差方向依此类推。它做的事情是用少数几个方向的线性组合来近似原始数据所以前提是原始特征之间存在线性相关性。这就引出了 PCA 的一个常见误用对本来就不相关的特征做 PCA。如果 20 个特征两两相关性都很低PCA 压到 5 维会丢掉大量独立信息效果必然下降。判断依据很简单先看相关矩阵import seaborn as sns import matplotlib.pyplot as plt from sklearn.decomposition import PCA from sklearn.preprocessing import StandardScaler corr df[num_cols].corr() # 看是否存在成块的高相关区域 # 有高相关再做 PCA且必须先标准化 X_scaled StandardScaler().fit_transform(df[num_cols]) pca PCA(n_components0.95) # 保留 95% 方差 X_pca pca.fit_transform(X_scaled) print(pca.explained_variance_ratio_)另一个关键前提PCA 之前必须标准化。因为 PCA 是按方差最大化工作的一个量纲是万元、一个是百分比的特征方差天然差几个数量级PCA 会认为万元那个特征信息量更大这完全是被量纲误导的。4.2 特征选择比 PCA 更值得优先考虑如果目的是减少特征数量而不是压缩成新特征那特征选择通常比 PCA 更好原因有两个一是保留原始特征的物理含义模型结果可以解释、可以交付给业务方二是避免了 PCA 那种全局线性组合带来的信息混淆。三类方法的差异方法类型代表方法优点缺点过滤式方差阈值、卡方、互信息、相关系数快与模型无关忽略特征间组合效应包裹式RFE、前向/后向搜索考虑组合效应效果通常最好计算量随特征数指数增长嵌入式L1 正则、树模型 feature_importance训练即选择效率高依赖所选模型实际项目里我的顺序是先过滤掉方差接近 0 的和互信息极低的把特征数砍到几十个量级然后用树模型跑一遍看feature_importances_最后如果要精挑细选再上 RFE。嵌入式的 L1 正则Lasso也很实用它会把不重要特征的系数直接压到 0天然带选择功能。提示feature_importances_在存在高相关特征时会把重要性摊薄几个相关特征各分到一部分看起来都不重要。这时应该看排列重要性permutation_importance它通过打乱单列观察性能下降来评估更可靠。4.3 降维的收益场景和禁区降维真正的收益场景是特征数远大于样本数比如基因数据几万特征、几百样本、或者存在严重多重共线性导致模型不稳定、或者线上推理有严格的延迟和内存约束这也呼应了那些追求极小体积模型的需求参数量越小越省资源。禁区也很明确树模型 中等特征量 充足样本这三种条件同时满足时降维几乎总是负收益。树模型自带特征选择能力它会自动忽略无用的列而 PCA 生成的组合特征破坏了单特征的可解释切分逻辑往往让树模型表现变差。我自己在表格类竞赛里基本不做 PCA只做特征选择。5. 不同数据形态预处理的重点完全不同5.1 表格数据把清洗和编码做扎实就够了表格数据是预处理里最标准的一类流程也最成熟缺失处理 → 异常处理 → 类别编码 → 数值缩放视模型 → 特征交叉。核心经验是别过度设计表格数据的信号往往集中在少数几个关键特征上把这几列的清洗做干净比堆一百个交叉特征有用。5.2 图像数据归一化是底线增强要匹配任务图像预处理的必修课是统一尺寸和归一化。深度学习框架里的预训练模型通常期望特定输入范围比如 ImageNet 预训练模型要求按均值和标准差做标准化from torchvision import transforms train_tf transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(), transforms.RandomResizedCrop(224, scale(0.8, 1.0)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])这里的均值和标准差是 ImageNet 统计出来的用预训练权重就跟着用它自己从零训练才需要算自己数据集的统计量。数据增强的坑在于增强方式必须和任务语义一致通用分类任务做水平翻转没问题但识别数字、识别方向敏感的目标、或者医学影像里左右有明确解剖意义的情况翻转会制造错误标签。同理颜色抖动对判断颜色是否为判别依据的任务就是有害的。5.3 文本数据清洗词表和长度对齐文本预处理的关键决策在分词和截断长度上。分词器要和预训练模型匹配比如 BERT 系列用 WordPiece很多中文场景用字级别或者专门的切分工具。截断长度是成本和效果的权衡——定得太短长文本的关键信息被砍掉定得太长显存和计算量飙升而且大部分位置是填充 token浪费算力。我的做法是先统计训练集文本长度的分位数取 95% 分位作为截断阈值这样只牺牲 5% 的尾部样本。另外文本清洗要不要去停用词、要不要去标点取决于任务。情感分类里不这类否定词绝对不能删但做主题聚类时删掉噪点符号能提升效果。5.4 时序数据窗口构造和防泄漏是全部时序数据最容易出错的地方是划分离。绝对不能随机划分训练集和测试集必须按时间顺序切否则就是用未来的数据预测过去。窗口构造的逻辑是用过去 N 个时间步预测下一个值或下一个标签。def make_windows(series, window24, horizon1): X, y [], [] for i in range(len(series) - window - horizon 1): X.append(series[i:i window]) y.append(series[i window horizon - 1]) return np.array(X), np.array(y)时序特征的缩放也要小心用全局均值和标准差去标准化整条序列等于把未来的分布信息带进了训练集。正确做法是用训练段算出的统计量去变换验证段和测试段。6. 用 Pipeline 串起来避免数据泄漏6.1 数据泄漏是怎么悄悄发生的数据泄漏是预处理器里最常见、最隐蔽、后果最严重的错误。它的典型形态是在划分训练测试集之前就做了标准化或者填充导致测试集的统计信息均值、中位数、最大最小值泄漏进了训练过程。后果是什么离线评估分数虚高你以为模型能到 0.9上线只有 0.75。而且这个错误在单次实验里完全看不出来因为没有任何报错代码跑得飞快分数还很漂亮。正确顺序只有一个先划分再只对训练集 fit 所有预处理然后 transform 验证集和测试集。from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy ) # 错先标准化再划分 # X_scaled scaler.fit_transform(X); train_test_split(X_scaled, ...) # 对先划分scaler 只在 train 上 fit scaler StandardScaler().fit(X_train) X_train_s scaler.transform(X_train) X_test_s scaler.transform(X_test)交叉验证里也是同理。如果你在 CV 外面做了一次fit_transform那每一折的验证集其实都被自己的统计量污染过。正确做法是把预处理放进 Pipeline让 CV 在每一折内部独立完成 fit 和 transform。6.2 ColumnTransformer Pipeline 的写法实际项目里数值列和类别列需要不同的处理逻辑ColumnTransformer就是干这个的from sklearn.compose import ColumnTransformer from sklearn.pipeline import Pipeline from sklearn.impute import SimpleImputer from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.ensemble import GradientBoostingClassifier num_pipe Pipeline([ (impute, SimpleImputer(strategymedian)), (scale, StandardScaler()), ]) cat_pipe Pipeline([ (impute, SimpleImputer(strategyconstant, fill_valueUnknown)), (encode, OneHotEncoder(handle_unknownignore)), ]) preprocessor ColumnTransformer([ (num, num_pipe, num_cols), (cat, cat_pipe, cat_cols), ]) full_pipe Pipeline([ (prep, preprocessor), (clf, GradientBoostingClassifier(n_estimators300, learning_rate0.05)), ]) full_pipe.fit(X_train, y_train) print(full_pipe.score(X_test, y_test))这么写有三个实际好处一是泄漏风险从结构上被消除二是整个流程可以作为一个对象序列化保存线上推理时直接调predict不会出现训练时的预处理忘了搬到线上这种事故三是超参数搜索时可以把预处理参数一起搜比如prep__num__impute__strategy可以在网格里直接切换。6.3 处理类别不平衡别在训练集上乱采样类别不平衡的采样操作过采样、欠采样、SMOTE必须是训练集内部的操作而且如果配合交叉验证采样要在每一折的训练部分内部做。SMOTE 的原理是在少数类样本之间做线性插值生成新样本如果对全量数据做了 SMOTE生成的合成样本可能落在验证集附近造成评估虚高。我自己的经验优先级是先看能不能用class_weightbalanced或者scale_pos_weight调整损失权重这个方法不改变数据分布风险最低确实需要采样时再考虑 SMOTE并且只在训练折内做。7. 踩过的坑和一份随手可用的检查清单7.1 三个我真实踩过的预处理坑第一个坑训练集和测试集用了不同的填充策略。早期做项目时我在训练脚本里用中位数填充在推理脚本里忘了加填充逻辑模型直接报错输入维度不对改了一版又用了均值填充。结果离线分数不变、线上诡异波动。后来强制自己把所有预处理都写进 Pipeline这类问题再没出现过。教训是预处理逻辑必须和数据、模型一起打包不能靠记得。第二个坑对高基数类别做了目标编码还在全量数据上算的均值。CV 分数 0.91提交上去 0.74。排查了一整天才发现是目标编码的泄漏。修正方式就是前面说的折内计算加平滑分数降到了 0.83但上线后稳定在 0.82这才是真实的水平。第三个坑图像任务里对整张数据集算了均值和方差。这在学术界的小数据集上几乎没人在意但在严格评估里就是泄漏。正确的做法是用训练子集算统计量或者干脆用预训练模型自带的 ImageNet 统计量。7.2 一份贴在显示器前的检查表我现在的习惯是每个新项目开始前把这张表过一遍能省掉大量返工划分顺序是不是先划分再用训练集 fit 所有转换器有没有任何一步的统计量是从全量数据算出来的缺失处理每一列的缺失比例是多少缺失本身是不是一个信号填充值是从训练集算的吗异常值判断为异常的依据是什么是错误还是真实信号下游模型对异常值敏感吗缩放下游模型是否对量纲敏感选的是 Standard、MinMax 还是 Robust依据是什么编码类别基数多少独热会不会炸维度目标编码是不是折内做的平滑参数怎么定的一致性训练和推理的字段格式、单位、编码方式是否完全一致有没有把预处理器单独保存可复现random_state都设了吗划分是否分层特别是类别不平衡时整个流程能不能从原始数据一键重跑这些条目看起来琐碎但每一条背后都对应着一类会实际影响结果的问题。预处理的本质不是把数据洗干净而是确保模型看到的训练数据和线上真实数据服从同一个分布且没有任何未来的、标签的信息混进来。抓住这一条很多看起来玄学的效果波动就都能解释了。最后说一个我自己的习惯每次写完预处理代码我会专门抽时间打印几个中间结果——填充后的缺失率是不是归零了、缩放后的均值和方差是不是符合预期、编码后的特征维度是多少、训练集和测试集在关键特征上的分布是不是接近。这几行 print 花不了两分钟但帮我逮住过至少三五次严重的错误。预处理是个没有捷径的活但如果把每一步的判断依据都想清楚它反而是整个建模流程里最可控、回报最确定的部分。