
“这数据也太脏了。”——这是半年前我接手那个新项目时冒出来的第一句话。系统导出的订单表里日期有的带斜杠有的带横杠客户ID前面莫名其妙多了一串空格折扣字段里还混着“无”“N/A”和“--”三种写法业务部门还坚称“数据都是好的你们直接用就行”。现实给了我一巴掌。整个清洗过程前前后后改了七版脚本后来项目上线不到一个月又因为特征口径不一致返工了一次就因为当初预处理流程没有固化下来。那件事之后我把数据预处理重新拆解了一遍——不是按网上的博客模板而是按自己踩坑的顺序从拿到原始数据到喂给模型每一个环节该看什么、该算什么、该保留什么最后沉淀成一套完整流程。这篇文章就是把那套流程展开讲透适合做数据分析、跑机器学习模型或者正在从零搭数据管道的朋友。你不需要是算法专家只要手上有一份乱糟糟的数据按这套流程走一遍基本能把坑提前踩完。1. 拿到原始数据的第一件事先别急着动手清洗大多数人拿到表格的第一反应是直接写代码删缺失值、改格式、去重复。这其实是反的。我见过最惨的一次经历是同事拿到一份用户行为日志第一行就开干去重结果两万多条数据被他删掉了四千多条。后来核对业务才发现同一个用户在一天内打开同一个页面两次这本身就是两条正常记录被他当成重复数据清掉了。这一删整个留存率模型直接失真。正确的顺序是先做探索性分析把数据的全貌摸清楚再决定每个字段要怎么处理。所谓“探索”核心就三件事结构、质量、分布。首先是看结构。我会跑一遍df.info()把这个输出当成诊断书。每一列的数据类型是不是符合预期、有没有空值、总共有多少个特征、多少个样本都在这几行里。这份输出里藏着一个特别常见的坑字段类型和实际内容对不上。比如把价格存成了字符串或者把日期字段自动解析成了 object 而不是 datetime。这个时候不要急着转类型先弄清楚为什么对不上——很多时候是数据源头的问题源头不改后面怎么处理都是擦屁股。紧接着是看质量。我习惯用df.isnull().sum()统计缺失值但更关键的是缺失率就是缺失数量占总样本的比例。缺失率低于1%的列基本可以直接忽略想办法填充或者直接删掉都不影响大局但如果某列缺失率超过50%就要停下来问业务方这个字段是干什么用的、为什么缺这么多。是采集的时候漏了是字段新增得太晚老数据没有还是本身这只对部分用户有意义这三个问题的处理方式完全不同。最后是看分布。用df.describe()看数值列的均值、标准差、四分位数再用df[列名].value_counts(normalizeTrue)看类别列的取值频率。这一步会暴露两种问题一是离群值比如订单金额有个999999二是脏类别值比如性别有“男”“男 ”“M”“男性”四种写法明显该合并。这两种问题现在先记录不要急着改等摸完全貌后统一处理。我当时拿到的这份订单数据做完探索性分析后列了一张问题清单问题类型具体表现影响信息缺失客户等级字段缺失38%无法直接用于建模日期混乱三种格式混用部分为纯文本无法排序和计算间隔重复记录订单号重复但内容不同ID唯一性失效异常值订单金额出现负数直接污染统计口径脏类别支付方式有7种写法编码后特征爆炸这张清单就是后面所有处理动作的索引。所有操作围绕它展开而不是凭感觉想到什么处理什么。摸清全貌这一步看似不产出东西实际上决定了后面每一步的正确性值得多花半小时。2. 缺失值处理分情况施策而不是无脑填充缺失值是最常在预处理里被随手处理掉的环节。很多新手一看到空的单元格就启动一套默认流程数值列填均值类别列填众数完事。但缺得多的列填充本身就是在制造假数据。先想清楚一个根本问题数据为什么会缺失统计上有三种缺失机制——完全随机缺失、随机缺失、非随机缺失。完全随机缺失指的是某个值是否缺失跟任何变量都无关那直接删除或者简单填一下影响不大。随机缺失指的是缺失跟其他字段有关比如高消费用户更不愿意填收入收入就受消费水平影响这类情况不能简单删需要按分组统计或其他变量辅助填充。最麻烦的是非随机缺失缺失本身就跟这个字段的真实值有关比如收入高的人反而容易填“不透露”数据缺失本身就成了一个信号这时候填充反而会掩盖信息更好的做法是把“是否缺失”单独做成一个特征传给模型。判断完机制再看缺失率。我有一条经验线缺失率低于5%的列用最常规的中位数、众数或直接丢弃对应行都可以5%到30%之间要认真评估填充方法考虑增加“缺失标记”列超过30%的除非这个特征非常关键否则我倾向于直接删掉尤其是纯文本类别列补了也是噪声。填充方法的选型逻辑我用这张表总结过字段类型简单填充法进阶填充法说明数值型连续中位数回归预测填充有偏分布不用均值用中位数更稳数值型离散众数按分组众数填充结合关联字段分组后填比全局众数准得多有序类别众数按相邻值填充顺序很重要别破坏等级关系无序类别“未知”类单独标记为缺失新增“未知值”本身是有效信息时间序列前向填充插值法时间顺序不可乱不要用全列均值为什么数值列我建议用中位数而不是均值因为现实中大部分业务数据都是偏态分布。比如订单金额极少数大额订单会把均值拉得很高均值填充会让大多数样本凭空多出一截金额模型学到的特征分布就歪了。中位数对异常值和偏态天然鲁棒适合作为默认选择。如果列分布接近正态用均值也没有问题但前提是先画过分布图不是想当然。分组填充是我最推荐的中级方法。假设用户年龄有缺失但你可以用注册时长或消费水平分组在组内填充。这背后的逻辑很简单相近的用户行为更可能相似用同组的中心值填充比全表一个值合理得多。实操上就是用df.groupby(分组字段)[目标字段].transform(lambda x: x.fillna(x.median()))。这里有个小技巧transform返回的是和原 DataFrame 相同索引的结果可以直接赋回去不会打乱行顺序新手经常在这一步用错agg想半天索引为什么对不上。我在实际项目里还发现一个很有用的习惯——给缺失值做“审计列”。也就是在处理之前先对每一个有缺失的字段记录缺失率、填充方法、填充依据之后模型出来效果不好排查特征问题时这份审计表能帮你十分钟定位到是不是处理策略本身有问题而不用把整个流程重跑一遍。3. 重复值与异常值哪些该删哪些该修重复值听起来简单实际上这里的坑比缺失值深得多。原因是“重复”这个概念在不同场景下有完全不同的定义。最直观的是完全相同重复即一行数据里的所有字段都一样这种基本可以放心删。但我在实际项目中遇到最多的是部分字段重复的场景——比如同一个订单号出现了两次但是收货地址不同或者支付时间不同。这种最考验业务理解。我当时的第一版脚本就不小心把订单号和订单行号混为一谈了同一个订单本来就有多个商品明细订单号必然重复根本不该去重。结果一删大量明细行丢失最后花了一个晚上从备份里恢复。所以动手去重之前一定要先搞清楚哪一列或哪几列组合起来才构成唯一标识这个唯一标识是业务上的真实标识而不是你觉得应该唯一的列。搞清楚了之后再决定是直接删重还是需要对比重复记录之间的差异确认是否需要保留最新的一条。通常我的处理逻辑是完全重复的直接删关键标识列重复的先输出重复项清单人工核对有更新时间字段的保留较新记录并记录日志。推荐用df.drop_duplicates(subset[订单号], keeplast)这类做法但前面那个keeplast一定要理解清楚——它是保留最后一次出现的那条不是任选一条。如果数据是按时间追加的通常是新记录更准。说完重复值再说异常值。异常值的检测不是单纯的统计学问题它同样依赖业务常识。统计学上有两个常用方法三倍标准差法和四分位距法。三倍标准差法把偏离均值三个标准差以上的点视为异常适合近似正态分布的数据IQR法用 Q1-1.5IQR 和 Q31.5IQR 划边界对偏态分布更稳健尤其是订单金额这种长尾数据IQR法远比比标准差靠谱。我经常看到新手对一份严重右偏的销售数据用三倍标准差结果把一大批正常的超大订单全部标红阈值完全失真。但比统计学检测更前置的是业务逻辑检测金额不可能是负数、年龄不可能超过120岁、折扣不可能大于1。这类规则建立起来一点都不难查出来的问题却通常是数据链路源头的脏数据远比统计离群点严重。我处理过一份销售额数据里出现负数的案例后来发现是退款订单被单独记录后又参与了汇总负数只是个标记根本不是异常业务规则一澄清就明白了。处理异常值有三种方式删除、修正、封顶缩尾。哪种方式用什么契机我给自己定的原则是如果异常值是录入错误且无法还原删如果能从关联字段推算真实值修如果异常值是真实存在的长尾极端情况比如直播带货的大促爆单不删也不用改用封顶缩尾把超过99%分位数的值压到99%分位数的水平既保留了分布形态又避免极端值干扰模型收敛。顺带提一个很多文章不会讲的细节异常值处理完之后一定要做一次前后对比。我习惯每个关键数值列在清洗前后都存一份分布概要均值、中位数、标准差对照看一眼就知道这步操作到底改变了什么。这个习惯帮我抓出过一次严重事故——客户ID列处理完后产生了大量 NaN是因为代码里的索引写错了如果没有对比分布概要那批数据直接进模型就是一场灾难。4. 类型统一与特征构造让每一列都变成模型能听懂的语言缺失值、重复值、异常值处理完之后这张表里剩下的大多是“不脏但乱”的数据——类型不对、格式不一、文本夹杂符号。这些字段不清理干净后续分析计算全是隐性错误。先处理日期。现实中日期是重灾区。我当时拿到的数据里日期的写法有 “2024/1/5”“2024-01-05”“24.1.5”三种还有一种居然是“20240105”的整数字段。统一要用pd.to_datetime()指定格式解析解析完检查一下是否产生了NaT因为解析失败的值会被转成缺失这时候需要回到原始字段看下是不是有特殊写法没覆盖到。日期转好之后不要只存一个 datetime 类型就完事通常还要拆出年、月、日、星期、是否节假日等衍生特征这些在后续分析里会反复用到。接着是字符串统一。分类字段里最常见的脏值问题就两类一是首尾空格二是同一含义的不同写法。空格用strip()就能解决不同写法合并则需要列一个映射表比如“男”“男 ”“男性”“M”统一成“男”。字符大小写的差异也属于这类统一成小写是最省事的方案。这块没有捷径只能靠value_counts()逐个检查看得多了之后哪些字段容易脏基本一眼就有感觉。类型统一之后就是特征构造。这一步很多人会忽略觉得把表格洗干净直接丢模型就好。但换个角度想模型能从数据里学到的规律本质上都是特征的直接映射。缺失的往往是变量之间关系的表达。还是拿订单数据举例。假设原始表有下单时间和发货时间两个字段那“发货耗时发货时间-下单时间”就是一个高价值特征客服效率、供应链健康度都藏在里面。再比如有商品单价和商品数量那“客单价金额/数量”就是比两个原始字段更直接的描述。如果数据带有时间属性还可以按用户维度聚合出最近30天下单次数、平均间隔、消费总额这类历史行为特征。做特征构造时我有一个原则先问业务目标。如果你是做用户流失预警那特征要围绕“活跃度”来构造如果你是做销售额预测那特征要围绕“历史购买力”来构造。构造特征不是炫技不是为了凑数是围绕某个明确的预测目标设计信息表达方式。我在第一个项目里就吃过亏——一通操作构造了几十个特征后来用特征重要性一分析大部分跟目标变量零相关白白增加了训练成本。特征数量也不是越多越好。相关性过高的特征会导致多重共线性让模型变迟钝。构造完之后先算一下特征间相关系数看到相关系数绝对值超过0.8的保留其中一个删除另一个。这一步虽然朴素但对后续模型稳定性的帮助非常直接。构造好新特征之后把原始字段中失去信息的列清理掉比如订单号这种纯ID列留下来也是噪声。到这里这张表在形态上已经接近一个合格的建模数据了只剩下两个关键步骤编码和缩放。5. 分类变量编码与数值缩放选错方法会直接影响模型效果机器学习模型本质上只能吃数值分类字段必须先变成数值这就是编码。但编码不是一视同仁地全按0、1、2、3排下去。核心问题在于类别之间是否存在“顺序”关系。顺序关系的典型例子是会员等级普通会员、银卡、金卡、钻石卡等级之间有高低顺序数字大小也有意义。这种情况用标签编码OrdinalEncoder是合理的直接把等级映射成0到3模型能感知到“递增”的关系。但如果类别之间没有顺序——比如城市、支付方式、商品类目——还硬编码成0、1、2就是在给模型灌输一个根本不存在的先后关系。比如支付方式微信0、支付宝1、银行卡2模型可能会错误地学习到银行卡“大于”支付宝这种信息。这种情况正确做法是用独热编码OneHotEncoder把每个类别拆成一列取值0或1。这两种编码的选择有一个量级参考类别数很少低于几十个直接用独热编码没问题类别数特别多比如用户ID有上万个独热编码会让特征矩阵爆炸这时候用目标编码或嵌入方式更合适。新手建议从独热编码开始稳妥不容易出错。编码完成之后数值列还面临一个尺度问题。各字段的单位差异很大订单金额可能是几百到几十万用户年龄是0到100这两个数值直接丢给模型金额列的绝对数值会占据主导地位年龄的贡献被淹没。这就是为什么要缩放。缩放的典型方法有三个。StandardScaler标准化把数据变成均值0标准差1适合数据本身接近正态分布的情况也是目前默认选项。MinMaxScaler归一化把数据压到0到1之间适合分布有明确上下界的情况比如像素值。RobustScaler稳健缩放则利用中位数和四分位距对异常值不敏感适合含有离群值的特征。我在实际项目里通常优先用RobustScaler原因很简单——现实数据很少是干净的规则分布稳健缩放能在异常值还没有完全消除的情况下保住模型效果。编码和缩放有一个比方法本身更重要的原则fit和transform必须分开。fit是在训练集上学习缩放参数均值和方差transform是把参数应用到数据上。测试集和未来的线上数据只能调用transform绝不能用全部数据一起fit否则测试集的信息就“泄漏”到训练集里了。这个点违反了很多人我单独留一节细讲。选择编码和缩放方法我一般会做一次小实验用同一份数据分别跑两种方案对比线上指标而不是拍脑袋决定。因为不同算法的偏好差异比你想象的大得多比如树模型对缩放基本无所谓而神经网络、基于距离的模型对缩放则极度敏感所以选型永远跟下游模型绑定在一起考虑。6. 训练、验证、测试集切分顺序问题引发的数据泄漏数据泄漏这个词听起来很高级其实本质就一句话模型接触了它不该接触的信息。预处理里的泄漏极其隐蔽因为操作看起来完全合法。最常见的做法是把全部数据处理好之后再一次性切分出训练集和测试集。很多人就是这么干的却不知道这里已经泄漏了——缩放用的均值和标准差来自全量数据也就是说测试集的信息参与了训练数据的构造模型在训练时已经“见过”测试集的统计量。这相当于考试前偷偷看了答案考出来的分数自然虚高。正确做法是先把数据切成训练集和测试集再分别在两块数据上做同样的预处理。也就是所有基于统计量的操作填充中位数、标准化、缩放都必须先用训练集的数据计算参数然后把参数直接应用到测试集。回到代码就是from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler X_train, X_test, y_train, y_test train_test_split(X, y, test_size0.2, random_state42) scaler StandardScaler() scaler.fit(X_train) # 用训练集计算均值和方差 X_train_scaled scaler.transform(X_train) X_test_scaled scaler.transform(X_test) # 直接应用训练集学到的参数上面fit_transform只出现在训练集上测试集只用transform。这个顺序一旦反了模型的泛化能力评估就失真了。除了缩放另外一个泄漏来源是缺失值填充。如果你用全量数据的中位数填充缺失还是在泄漏。标准做法同样是训练集算中位数然后测试集用同一个值填充。我习惯把所有预处理包成一个变换器训练集上跑fit_transform测试集上跑transform天然杜绝这类问题。还有一个切分方式的问题。如果数据本身带时间属性比如用户行为日志、销售记录随机切分其实不合理。通常应该按时间切分比如前80%作为训练集后20%作为测试集。原因在于时间序列数据具有连续性用过去预测未来才是真实场景训练时如果混进了未来的数据等于偷看底牌。场景切分方式原因普通分类/回归任务随机切分分层抽样保证类别比例一致类别不平衡按类别比例分层切分stratify避免某类样本全落在测试集时间序列/订单数据按时间先后切分模拟真实预测场景小样本数据多次切分取平均减少随机性影响随机切分时要带上stratify参数让训练集和测试集的类别比例保持一致尤其是二分类任务中正负样本本身就悬殊时分层必不可少。我见过有人不做分层切分结果测试集里几乎没有正样本模型预测结果看着很高实际上企业根本没法用。另外切分时一定要设置random_state这不只是为了复现更是为了让你后面调参数时能确定效果的差异是来自模型还是来自数据波动。还有个很多人忽视的点从train_test_split里出来的结果还要再做一次形状检查。项目初期经常因为特征列数不一致导致报错检查形状能提前发现问题等你跑到模型训练那一步再发现回头找哪里的尺寸不对特别浪费时间和精力。7. 把散装步骤固化成一个可复用的Pipeline前面每一步单独拎出来都不难真正的难点在于所有步骤面对新数据时要能原样重跑一遍。我第一次做项目时没有这个概念——清洗脚本是清洗脚本编码缩放是编码缩放测试集处理再复制一遍改改参数。当时跑起来还能凑合等数据源一更新全套流程的顺序、参数、方法全靠脑内回忆任何一步改动都会牵扯下一步维护成本像滚雪球一样往上涨。后来我彻底改用了 Scikit-learn Pipeline把所有步骤串成一条流水线新数据进来一条命令跑完效果一致、逻辑清晰、可复现。Pipeline 的核心思想是把“预处理建模”打包成一个整体。下面是一个浓缩的示例from sklearn.pipeline import Pipeline from sklearn.compose import ColumnTransformer from sklearn.impute import SimpleImputer from sklearn.preprocessing import StandardScaler, OneHotEncoder # 数值列中位数填充 标准化 num_pipeline Pipeline([ (imputer, SimpleImputer(strategymedian)), (scaler, StandardScaler()) ]) # 类别列众数填充 独热编码 cat_pipeline Pipeline([ (imputer, SimpleImputer(strategymost_frequent)), (onehot, OneHotEncoder(handle_unknownignore)) ]) preprocessor ColumnTransformer([ (num, num_pipeline, num_cols), (cat, cat_pipeline, cat_cols) ]) # 整体流水线预处理 模型 full_pipeline Pipeline([ (preprocessor, preprocessor), (model, LogisticRegression()) ]) full_pipeline.fit(X_train, y_train) y_pred full_pipeline.predict(X_test)这套写法的好处不只是代码优雅而是它强制你把流程中每一步的逻辑固定下来。ColumnTransformer把特征按类型分流处理的结构清晰体现在代码里哪列走什么流程一眼可见省掉了很多临时变量的传递。Pipeline 还有一个杀手锏——和交叉验证配合非常方便。直接cross_val_score(full_pipeline, X_train, y_train, cv5)就会在每一折训练中都自动用当前折的数据去拟合预处理参数然后再变换验证折顺序完全正确泄漏风险直接从机制上堵死了。如果用我前面那种手写流程配合交叉验证很容易在某个折上忘了重新拟合预处理参数悄悄泄漏进去效果虚高还不自知。流固化之后还有一个容易被忽略的环节——保存。用joblib.dump(full_pipeline, preprocess_pipeline.pkl)把训练好的整个 pipeline 存下来线上的新数据就直接load后transform不需要再重新拟合任何参数。这里要特别注意模型上线后线上数据的预处理只能通过这个保存好的 pipeline 做所有统计参数都是固定的不能每次重新 fit。一旦重新 fit线上和训练时的预处理口径就出现了偏差模型的表现会发生不可控的漂移。另外一个值得养成的习惯是在 Pipeline 里顺手加入drop步骤把不需要的ID、原始日期等无用列在入口直接丢掉防止脏列混到模型里。Pipeline 还有个隐藏用法是可以插入自定义变换器比如在数值列后面加一个“是否缺失”标记列通过FunctionTransformer或自定义的类实现让缺失标记也参与建模。这个技巧在非随机缺失的场景下特别有用模型能学到“这个值缺失本身就是一个重要信号”。把散装步骤收敛成 Pipeline 之后整个预处理就像一个黑盒接口——训练时 fit预测时 transform模型更新迭代时只需要替换中间的模型组件前后流程完全不动。你现在再回头看前面那些手写的清洗脚本就会意识到一个工程化的预处理流程重点从来不是某一步操作本身而是每一步操作之间的顺序、参数和复用机制是否被稳稳地固化住了。我后来每次接到新项目第一件事就是先搭 Pipeline 骨架再把探索阶段发现的清洗规则填进去后面所有改动都只改配置不动流程。这条路走通之后数据预处理这个环节基本就从每天焦头烂额的临时救火变成了一条安静运行的流水线。