数据预处理实战指南:缺失值、异常值与特征缩放全流程
1. 数据预处理在整个清洗链路里到底站在什么位置很多人做数据分析一上来就急着跑模型、画图表结果发现准确率上不去、图形歪歪扭扭回头一查问题全出在最前面的数据预处理环节。我做了十多年数据相关的工作可以很负责任地讲一句数据预处理做得好不好直接决定了后面所有分析工作的天花板。你模型再花哨、可视化再漂亮喂进去的是一堆脏数据出来的结论就是空中楼阁。先把这个概念说清楚。所谓数据预处理指的是在正式分析或建模之前对原始数据做的一系列规范化操作包括缺失值处理、异常值处理、数据类型转换、重复值剔除、格式统一、量纲归一化等等。它和数据清洗这两个词经常被混着用但严格来说数据清洗更偏向于把错的改对、把缺的补上、把重的删掉而数据预处理的范围更宽还包含了为后续建模服务的特征变换工作比如标准化、归一化、离散化。为什么它这么关键我举个生活化的例子。你要做一道菜食材买回来带着泥、有的烂了一半、有的切得大小不一。你如果直接下锅这道菜的味道必然不稳定。数据预处理就是洗菜、择菜、切配的过程。食材处理得干净整齐后面炒菜建模才能稳定发挥。在真实项目里原始数据往往来自多个渠道格式五花八门缺失和异常是常态不做预处理直接分析基本等于自找麻烦。从整个数据处理的链路来看数据预处理通常处在数据获取之后、特征工程与建模之前。它承上启下向上承接原始数据的采集结果向下为特征工程和模型训练提供干净、规整的输入。这个位置决定了它既不能省也不能马虎。我见过太多项目前期采集花了大价钱结果预处理草草了事最后模型效果差又回头返工浪费的时间比一开始认真做还多。这篇总结面向的是已经有一点Python基础、正在系统学习数据清洗的朋友。不管你是做业务分析、做算法还是做数据报表只要你的工作里要跟数据打交道这套预处理的方法论都用得上。我会把每一步的原理、为什么这么做、具体怎么用代码实现、以及我踩过的坑都讲透让你看完能直接上手抄作业。2. 缺失值处理先搞清楚为什么缺再决定怎么补缺失值是数据预处理里绕不开的第一道坎。但我要先泼一盆冷水看到缺失值就急着填充是最常见的错误做法。在动手之前你得先判断这个缺失是怎么来的因为缺失的原因决定了你该用什么策略。2.1 缺失值的三种类型与判断逻辑从统计学的角度缺失机制大致分三类。第一类是完全随机缺失比如某台传感器偶尔抽风漏采了一条记录这种缺失和任何变量都无关。第二类是随机缺失缺失的概率和某些已观测到的变量有关比如收入高的人群更不愿意填年龄。第三类是非随机缺失缺失本身就携带信息比如问卷里月消费这一栏空着的人往往就是消费极低或者极高的人。为什么要分这么细因为处理策略完全不同。完全随机缺失你删掉或者用均值填充影响都不大。但如果是非随机缺失你直接删掉就等于把一批有特征的人为样本扔了模型会系统性偏差。我一般的做法是先统计每一列的缺失比例再结合业务含义判断缺失原因。import pandas as pd import numpy as np df pd.read_csv(raw_data.csv) # 查看每列缺失数量和比例 missing pd.DataFrame({ 缺失数量: df.isnull().sum(), 缺失比例: (df.isnull().sum() / len(df)).round(4) }) missing missing[missing[缺失数量] 0].sort_values(缺失比例, ascendingFalse) print(missing)这段代码是每次拿到新数据后我必跑的第一步。缺失比例超过60%的列通常要慎重考虑是否保留因为填充的代价太大而且填充出来的数据可信度很低。2.2 删除、填充、还是保留三种策略的取舍判断完缺失情况接下来就是决策。我把常见策略整理成一张表方便你对照选择。策略适用场景优点风险整行删除缺失行占比极低如1%且完全随机简单、无偏样本量损失整列删除某列缺失比例过高如60%且非关键字段干净利落可能丢失重要信息均值/中位数填充数值型、随机缺失、分布较对称实现简单会压缩方差低估波动众数填充类别型、随机缺失保留主要类别分布会放大主类别占比前向/后向填充时间序列、相邻记录相关性强保留趋势连续缺失段会失真模型预测填充缺失比例中等、字段间相关性强精度高计算成本高、易过拟合均值填充有个坑我必须提醒如果数据分布是偏态的均值会被极端值拉偏这时候用中位数更稳。我做过一个用户消费金额的字段均值被几个大额用户拉得很高用均值填充后整体分布明显右移后来换成中位数才正常。# 数值型中位数填充比均值更抗极端值 df[消费金额] df[消费金额].fillna(df[消费金额].median()) # 类别型众数填充 df[城市] df[城市].fillna(df[城市].mode()[0]) # 时间序列前向填充 df[日活] df[日活].fillna(methodffill)2.3 填充之外给缺失本身留个记号这是我特别想强调的一个经验。当缺失可能携带信息时不要只填充还要额外加一个是否缺失的标记列。比如月消费缺失可能意味着用户是低活跃人群这个信息本身就有价值。加一列消费金额_是否缺失用0/1表示模型就能学到这个信号。df[消费金额_是否缺失] df[消费金额].isnull().astype(int) df[消费金额] df[消费金额].fillna(df[消费金额].median())这个技巧在风控、用户画像类项目里特别管用。我踩过的坑是早期做用户流失预测时直接把缺失填了结果模型完全学不到信息缺失这个强信号召回率一直上不去。后来加了标记列效果立竿见影。3. 异常值识别别急着删先分清是错误还是真相异常值处理是另一个容易翻车的地方。新手看到离群点第一反应是删掉但异常值里往往藏着最有价值的信息。欺诈交易、设备故障、爆款商品这些在数据上都是异常值。所以处理异常值的第一步不是删而是识别和判断。3.1 三种主流识别方法及其适用边界我常用的识别方法有三种各有各的适用场景。第一种是3σ法则标准差法。假设数据近似正态分布超出均值±3倍标准差的范围视为异常。这个方法简单但前提是数据得接近正态分布而且均值和标准差本身就会被极端值污染所以它适合数据比较干净、分布对称的场景。第二种是IQR四分位距法。计算第一四分位数Q1和第三四分位数Q3超出[Q1 - 1.5*IQR, Q3 1.5*IQR]范围的算异常。这个方法不依赖正态假设对偏态分布更稳健是我日常用得最多的。第三种是基于业务规则的阈值。比如年龄不可能超过120岁订单金额不可能为负这些硬性规则直接卡掉最可靠。# IQR法识别异常值 Q1 df[订单金额].quantile(0.25) Q3 df[订单金额].quantile(0.75) IQR Q3 - Q1 lower Q1 - 1.5 * IQR upper Q3 1.5 * IQR outliers df[(df[订单金额] lower) | (df[订单金额] upper)] print(f识别出异常值 {len(outliers)} 条占比 {len(outliers)/len(df):.2%})3.2 识别之后删除、盖帽还是分箱识别出来之后怎么处理取决于异常的性质。如果是明显的录入错误比如年龄填了999直接删或者修正。如果是真实的极端值我倾向于保留但可以做盖帽处理也就是把超过阈值的值统一压到阈值上避免它过度影响模型。# 盖帽处理把超出上界的值压到上界 df[订单金额] np.where(df[订单金额] upper, upper, df[订单金额])还有一种思路是分箱把连续值切成几个区间异常值自然被归入最高或最低的箱子里既保留了这个值很高的信息又削弱了它的绝对数值影响。这个方法在评分卡类项目里非常常见。注意异常值处理一定要留痕。我习惯把被处理掉的原始值单独存一份万一后面发现判断错了还能回溯。删数据一时爽返工火葬场。3.3 一个真实踩坑把真相当成了错误早些年做一个设备监控的项目某台设备的温度读数经常飙到远超正常范围。我第一反应是传感器故障直接把这些点当异常值删了。结果后来设备真的出了故障复盘时才发现那些异常读数正是故障前兆。数据不会无缘无故异常异常往往是系统在向你报警。从那以后我处理异常值都会先跟业务方确认绝不自己拍脑袋删。4. 数据类型与格式统一最不起眼却最容易埋雷的环节数据类型和格式问题看起来是小事但它埋的雷往往在最后才炸。我见过太多案例日期字段是字符串排序时按字典序排结果10月排在了2月前面数字字段混进了单位符号求和时直接报错。这些问题的根源都是预处理阶段没把类型和格式统一好。4.1 类型转换字符串、数值、日期的三角关系最常见的就是字符串和数值之间的转换。从CSV或Excel读进来的数据经常整列都是字符串哪怕看起来是数字。这时候要用astype或pd.to_numeric转换。# 强制转数值无法转换的设为NaN df[价格] pd.to_numeric(df[价格], errorscoerce) # 转日期指定格式更稳妥 df[下单时间] pd.to_datetime(df[下单时间], format%Y-%m-%d %H:%M:%S, errorscoerce)这里有个细节errorscoerce会把转换失败的变成NaN而不是直接报错中断。这个参数在真实数据里几乎是必加的因为原始数据里总有几个不听话的脏值。转换完之后记得再统计一下产生了多少NaN这些就是格式有问题的记录需要单独排查。4.2 文本字段的规范化空格、大小写、全半角文本字段的坑更隐蔽。用户填的北京和北京 带空格在程序眼里是两个不同的值Apple和apple也是两个。做分组统计时这些都会被拆开导致结果失真。# 去首尾空格 df[城市] df[城市].str.strip() # 统一大小写 df[品牌] df[品牌].str.lower() # 全角转半角处理中文输入法带来的全角字符 def to_halfwidth(s): if pd.isna(s): return s result for ch in str(s): code ord(ch) if code 12288: code 32 elif 65281 code 65374: code - 65248 result chr(code) return result df[备注] df[备注].apply(to_halfwidth)全半角这个问题做中文数据处理的人一定遇到过。用户输入法切换不当数字和字母可能变成全角肉眼几乎看不出来但程序处理时就是错的。这个转换函数我建议你直接收藏几乎每个中文项目都用得上。4.3 单位与量纲统一别让米和厘米打架还有一个高频问题同一字段混用不同单位。比如重量字段有的记录是kg有的是g直接参与计算就全乱了。处理办法是先提取单位统一换算成基准单位再转成数值。原始值提取数值提取单位换算后基准g2.5kg2.5kg2500800g800g8001.2KG1.2KG1200def parse_weight(s): if pd.isna(s): return np.nan s str(s).strip().lower() if kg in s: return float(s.replace(kg, )) * 1000 elif g in s: return float(s.replace(g, )) return np.nan df[重量_g] df[重量].apply(parse_weight)这类清洗逻辑没有通用库能一键搞定必须结合具体业务字段手写规则。我的经验是先把所有出现过的单位枚举出来写成一个映射表再统一处理这样不容易漏。5. 重复值与一致性校验数据照镜子的必修课重复数据是另一个隐形杀手。它不会报错但会悄悄扭曲你的统计结果。比如计算平均客单价时如果同一个订单被记了两次均值就被拉偏了。更麻烦的是部分重复——两条记录看起来不同但关键字段其实指向同一个实体。5.1 完全重复与部分重复的区分完全重复好办drop_duplicates()一行搞定。但部分重复需要你定义什么算重复。比如用户表里手机号相同但姓名拼写略有差异这算不算同一个人这就要靠业务判断了。# 完全重复所有列都相同 df df.drop_duplicates() # 部分重复按关键字段判断 df df.drop_duplicates(subset[手机号], keepfirst)keep参数有三个取值first保留第一条last保留最后一条False则把所有重复的都删掉。选哪个取决于你的业务逻辑如果是订单表通常保留最新的一条如果是去重统计可能全删更合适。5.2 一致性校验跨字段的逻辑矛盾排查比重复更隐蔽的是逻辑矛盾。比如出生日期晚于入职日期订单完成时间早于下单时间这些在单列看都没问题但跨字段一比对就露馅了。# 排查日期逻辑矛盾 mask df[完成时间] df[下单时间] print(f发现 {mask.sum()} 条时间逻辑异常记录) df.loc[mask, [下单时间, 完成时间]]这类校验我建议做成一个检查清单每次数据进来都跑一遍。常见的校验项包括日期先后关系、金额正负、枚举值是否在允许范围内、外键是否能关联上。把校验规则沉淀成脚本比每次手动查靠谱得多。提示一致性校验发现的异常不要急着自动修正先输出出来人工确认。自动修正一旦规则写错会把好数据也改坏。6. 特征缩放与数据变换为建模铺好最后一段路前面几步做完数据算是干净了但如果要拿去建模还差最后一步特征缩放和数据变换。这一步不是清洗脏数据而是让数据更适合算法消化。6.1 标准化与归一化到底该用哪个这两个概念新手最容易搞混。归一化Min-Max是把数据线性映射到[0,1]区间公式是(x - min) / (max - min)。标准化Z-Score是把数据变换成均值0、标准差1的分布公式是(x - mean) / std。那到底用哪个我的经验是如果算法对数值范围敏感、且数据分布没有明显边界用归一化如果数据有极端值、或者算法假设数据近似正态用标准化。像KNN、神经网络这类基于距离的算法缩放是必须的而树模型决策树、随机森林对缩放不敏感可以跳过。from sklearn.preprocessing import MinMaxScaler, StandardScaler # 归一化 mm MinMaxScaler() df[金额_归一化] mm.fit_transform(df[[金额]]) # 标准化 ss StandardScaler() df[金额_标准化] ss.fit_transform(df[[金额]])这里有个大坑缩放器必须只在训练集上fit然后transform测试集。如果拿全量数据fit测试集的信息就泄漏到训练过程里了模型评估会虚高。这个错误我见过太多人犯包括一些工作几年的同行。6.2 离散化与独热编码把类别变成模型能懂的语言类别型字段模型是没法直接吃的得转成数值。最简单的是标签编码给每个类别编个号但这样会引入不存在的大小关系比如把北京1上海2当成上海比北京大。更稳妥的是独热编码每个类别单独一列用0/1表示。# 独热编码 df_encoded pd.get_dummies(df, columns[城市], prefix城市)独热编码的代价是维度膨胀。如果一个字段有几百个类别编码后就是几百列容易导致维度灾难。这时候可以考虑目标编码或者频次编码作为替代。我一般会先看类别数量超过50个就慎重用独热。6.3 数据变换让偏态分布正过来有些字段天生偏态比如收入、消费金额大部分人集中在低值少数人极高。这种分布直接建模效果不好可以做对数变换把它拉回接近正态。df[收入_log] np.log1p(df[收入]) # log1p log(1x)避免0值报错np.log1p比np.log更安全因为它能处理0值。凡是涉及金额、计数类的偏态字段我都会先试试对数变换很多时候能明显提升模型表现。7. 把预处理串成一条可复用的流水线零散地写清洗代码做一两个项目还行项目一多就会乱。我的做法是把整个预处理流程封装成函数或Pipeline输入原始数据输出干净数据中间每一步都可配置、可追溯。7.1 用Pipeline组织流程sklearn的Pipeline能把多个预处理步骤串起来特别适合标准化、编码这类操作。但缺失值填充、异常值处理这些自定义逻辑Pipeline支持得不够灵活所以我更常用自定义函数配置字典的方式。def preprocess(df, config): df df.copy() # 1. 去重 df df.drop_duplicates(subsetconfig.get(dup_keys)) # 2. 类型转换 for col, dtype in config.get(dtypes, {}).items(): df[col] df[col].astype(dtype) # 3. 缺失值填充 for col, strategy in config.get(fillna, {}).items(): if strategy median: df[col] df[col].fillna(df[col].median()) elif strategy mode: df[col] df[col].fillna(df[col].mode()[0]) # 4. 异常值盖帽 for col in config.get(clip_cols, []): Q1, Q3 df[col].quantile([0.25, 0.75]) IQR Q3 - Q1 df[col] df[col].clip(Q1 - 1.5*IQR, Q3 1.5*IQR) return df这样写的好处是清洗规则全部外置成配置换一份数据只要改配置不用改代码。而且每一步做了什么配置里一目了然方便复盘和交接。7.2 处理顺序为什么不能随便调流水线里各步骤的顺序是有讲究的。我的经验顺序是先去重再处理类型然后处理缺失接着处理异常最后做缩放和编码。为什么这么排去重放最前面是因为重复数据会干扰后面所有统计量比如中位数、分位数。类型转换要在缺失处理之前因为填充策略依赖数据类型数值用中位数类别用众数。异常值处理放在缺失之后是因为填充可能引入新的极端值。缩放和编码放最后因为它们是为建模服务的不影响数据本身的干净程度。顺序调错会出什么问题我举个例子如果你先做了标准化再处理缺失值那填充的均值就是标准化后的0看起来没问题但标准化时用的均值和标准差是被缺失值影响过的结果就不准了。顺序错了结果可能不会报错但会悄悄偏掉这才是最可怕的。7.3 预处理结果的自检清单流水线跑完别急着往下走先做一轮自检。我习惯检查这几项缺失值是否还有残留df.isnull().sum().sum()是否为0数据类型是否符合预期df.dtypes数值范围是否合理df.describe()看min/max有没有离谱值类别字段的取值是否都在预期集合内处理前后的行数、列数变化是否符合预期def check(df): print(缺失总数:, df.isnull().sum().sum()) print(形状:, df.shape) print(df.describe().T[[min, max]])这几行代码花不了几秒钟但能帮你挡掉大部分低级错误。我见过太多人跳过自检结果把带NaN的数据喂进模型报了一堆莫名其妙的错排查半天才发现是预处理漏了。8. 几个让我印象深刻的预处理教训最后分享几个我在实际项目里踩过的坑都是文档里不会写、但特别值钱的经验。第一个教训不要用测试集的信息去填充训练集。有一次做用户评分预测我在全量数据上算了中位数去填充缺失结果模型在测试集上表现异常好上线后一塌糊涂。原因就是测试集的分布信息泄漏到了训练过程。正确做法是所有依赖统计量的操作填充、缩放、编码都只在训练集上fit再应用到测试集。第二个教训类别字段的取值会随时间变化。做电商项目时训练集里的商品类别是固定的几十种我用了独热编码。结果上线后出现了新类别编码时直接报错。后来改成先预留一个其他类别遇到没见过的值就归到其他里才稳定下来。预处理要考虑线上数据的动态性不能只盯着手头这份数据。第三个教训过度清洗会丢掉信号。前面提过异常值可能是真相其实缺失值也一样。我曾经为了追求数据完整把所有缺失都填得满满的结果模型完全学不到信息缺失这个信号效果反而不如保留缺失标记的版本。清洗的目标是让数据可用不是让数据完美。该保留的信息一定要保留。第四个教训一定要留原始数据备份。这个听起来像废话但我真的见过有人直接在原始文件上改改错了想回滚都回不去。我的习惯是原始数据只读所有清洗结果另存新文件中间过程也保留。数据清洗是不可逆操作留好退路比什么都重要。预处理这件事说到底是个磨刀的活。刀磨得好后面砍柴才快。它不炫技也不出彩但每一个做过真实项目的人都清楚数据预处理的质量就是整个数据工作的地基。地基不牢上面盖什么都是危房。希望这篇总结能帮你把这块地基打扎实少走一些我当年走过的弯路。