数据清洗这个事做过的人都知道它从来不是整个数据分析流程里最光鲜的那一环但绝对是绕不过去、也最耗时间的一环。我这些年带过不少刚入行的朋友几乎每个人都会在某个深夜对着一个满是缺失值和异常值的表格发呆然后跑来问我这数据到底该怎么下手这篇内容就是把我自己在数据预处理阶段反复踩坑、反复总结出来的东西系统梳理一遍。核心关键词就一个——数据预处理它是数据清洗流程里承上启下的关键步骤负责把原始数据变成能喂给模型或者能直接做统计分析的干净形态。不管你是刚学pandas的新手还是已经写过一些脚本但总觉得清洗逻辑不够系统的朋友这篇内容应该都能给你一些可以直接抄作业的思路和代码。1. 数据预处理到底在解决什么问题1.1 从一份脏数据说起我先描述一个几乎所有人都会遇到的场景。你从某个业务系统导出一份用户行为数据打开一看年龄列里混着未知和-1注册时间有的带时区有的不带消费金额里出现了负数还有几列干脆一半是空的。这时候你如果直接拿去做统计分析结果一定是错的而且错得毫无规律。数据预处理要干的事情说白了就是把这份脏数据变成干净数据。但干净这个词很模糊不同场景下标准完全不一样。做用户画像的时候年龄缺失可能要用中位数填充做风控模型的时候年龄缺失本身就是一个强特征不能随便填。所以预处理的第一个核心认知是没有通用的清洗方案只有匹配业务目标的清洗方案。我一般会把预处理拆成几个相对独立的子任务缺失值处理、异常值处理、数据类型转换、重复值处理、以及必要的标准化和编码。这几个任务之间有先后依赖关系顺序搞错了会返工。比如你先把字符串类型的1,234转成数字再去处理异常值就比反过来要顺得多。1.2 预处理在整个流程中的位置很多人会把数据清洗和数据预处理混着说我觉得有必要区分一下。数据清洗更偏向于修正错误比如把明显录入错误的手机号删掉、把重复订单去重而数据预处理更偏向于转换形态让数据适配后续的分析或建模需求。两者有重叠但侧重点不同。在实际项目里我习惯的顺序是这样的先做数据探查了解每一列的类型、分布、缺失情况然后做清洗处理掉明确的错误最后做预处理完成类型转换、缺失填充、标准化等操作。这个顺序不是死的但大方向是先诊断再治疗。提示不要一上来就写填充代码。先花十分钟把每列的缺失率、唯一值数量、最大值最小值打出来看一眼能省掉后面大量的返工。2. 缺失值处理最考验判断力的环节2.1 先搞清楚缺失的类型缺失值处理是预处理里最容易被轻视、但实际上最需要思考的部分。统计学上把缺失分成三类完全随机缺失、随机缺失、非随机缺失。听起来很学术但落到实操里其实很好判断。完全随机缺失就是缺失跟任何变量都没关系纯粹是偶然的比如传感器偶尔丢一个读数。随机缺失是缺失跟其他某个变量有关比如收入字段的缺失可能跟年龄段有关。非随机缺失是缺失跟缺失值本身有关比如高收入人群更不愿意填收入。为什么要分这个因为不同类型的缺失处理方式完全不同。完全随机缺失可以直接删或者用均值填非随机缺失如果直接填均值会严重扭曲分布。我见过太多人不管三七二十一全用均值填充最后模型效果怎么调都上不去问题就出在这里。2.2 缺失值处理的几种主流方法我把常用的方法整理成一张表方便你对照选择。方法适用场景优点风险直接删除行缺失率极低5%且完全随机简单、不引入偏差浪费数据样本少时影响大均值/中位数填充数值型、随机缺失实现快低估方差扭曲分布众数填充类别型、随机缺失保持类别合法可能放大某一类别前向/后向填充时间序列保持时序连续连续缺失时失真严重模型预测填充缺失率较高、变量相关性强精度高计算成本高可能过拟合单独作为一类非随机缺失保留缺失信息类别数增加我个人的经验是缺失率低于5%且完全随机的直接删5%到30%之间的优先考虑模型填充或者分组填充超过30%的要慎重考虑这一列是否还有保留价值除非业务上明确知道它重要。2.3 用pandas落地缺失值处理理论说完了上代码。先看怎么快速诊断缺失情况。import pandas as pd import numpy as np df pd.read_csv(user_behavior.csv) # 缺失情况总览 missing df.isnull().sum() missing_rate (missing / len(df)).sort_values(ascendingFalse) print(pd.DataFrame({缺失数: missing, 缺失率: missing_rate}))这段代码跑完你就能一眼看出哪些列问题严重。接下来是分组填充的一个典型写法比整体均值填充合理得多。# 按城市分组用组内中位数填充收入 df[income] df.groupby(city)[income].transform( lambda x: x.fillna(x.median()) ) # 类别型用众数填充 df[education] df[education].fillna(df[education].mode()[0])这里有个细节值得说transform和apply的区别。transform返回的是与原表等长的序列能直接赋值回去apply返回的是聚合结果长度可能对不上。填充场景下基本都用transform。注意分组填充时如果某个组内全是缺失值中位数会是NaN填充后还是NaN。稳妥的做法是填充完再兜底一次全局中位数。3. 异常值识别与处理别急着删3.1 异常值的两种来源异常值这个东西新手最容易犯的错就是一看到就删。但实际上异常值分两种一种是错误数据比如年龄填了200、消费金额是负数另一种是真实但极端的值比如某个用户确实一次性买了十万块的东西。前者必须处理后者要谨慎对待因为它可能恰恰是业务上最有价值的信息。风控场景里那些极端交易往往就是风险点如果无脑删掉等于把信号也删了。3.2 识别异常值的常用手段我常用的有三种基于统计的、基于分位数的、基于业务规则的。基于统计的就是Z-score方法一般认为Z-score绝对值大于3的是异常值。但它假设数据近似正态分布偏态严重的数据不适用。基于分位数的就是IQR方法计算Q1和Q3超出Q1-1.5倍IQR或Q31.5倍IQR的算异常。这个方法对偏态数据更稳健我用得最多。基于业务规则的就是硬编码比如年龄必须在0到120之间。这个方法最直接但需要业务知识。# IQR方法识别异常值 Q1 df[amount].quantile(0.25) Q3 df[amount].quantile(0.75) IQR Q3 - Q1 lower Q1 - 1.5 * IQR upper Q3 1.5 * IQR outliers df[(df[amount] lower) | (df[amount] upper)] print(f检测到 {len(outliers)} 条异常记录)3.3 处理异常值的策略选择识别出来之后怎么处理我一般分三种情况。第一种确认是错误数据直接删或者用边界值截断。截断就是把超过上界的值统一设为上界比直接删更保守保留了样本量。第二种不确定是不是错误先标记不删。加一列is_outlier让模型自己去学这个标记有没有用。这个做法在竞赛里很常见。第三种确认是真实极端值保留但考虑做对数变换来压缩量纲。对数变换对右偏的金额类数据特别有效。# 截断处理 df[amount] df[amount].clip(lowerlower, upperupper) # 对数变换 df[amount_log] np.log1p(df[amount])np.log1p是log(1x)比直接log好在能处理0值。金额类数据经常有0用这个更安全。4. 数据类型转换与格式统一4.1 类型不对后面全白费数据类型转换听起来简单但它是预处理里最容易埋雷的地方。我遇到过太多次数值列因为混了一个暂无变成了object类型然后所有数值计算全部报错或者静默出错。第一步永远是看df.dtypes。如果本该是数值的列显示为object那里面一定有非数值字符。# 强制转换无法转换的变为NaN df[price] pd.to_numeric(df[price], errorscoerce) # 转换后看有多少变成了NaN print(df[price].isnull().sum())errorscoerce这个参数非常关键它把无法转换的值变成NaN而不是直接报错让你能先看看有多少脏数据再决定怎么处理。4.2 日期时间格式的统一日期格式是另一个重灾区。同一个系统里日期可能有2024-01-01、2024/01/01、01/01/2024好几种写法。统一用pd.to_datetime处理配合format参数指定格式比让它自动推断更可靠。df[order_date] pd.to_datetime( df[order_date], format%Y-%m-%d, errorscoerce )如果格式不统一可以先做字符串替换把斜杠统一成横杠再转换。转换完之后还可以从日期里提取年、月、日、星期几等衍生特征这些在建模时往往很有用。df[year] df[order_date].dt.year df[month] df[order_date].dt.month df[weekday] df[order_date].dt.dayofweek4.3 文本数据的清洗文本列常见的脏法有前后空格、大小写不一致、全角半角混用、不可见字符。这些看起来是小问题但会导致分组统计时同一个类别被拆成好几个。# 去空格、统一小写 df[city] df[city].str.strip().str.lower() # 全角转半角针对数字和字母 def to_halfwidth(s): if pd.isna(s): return s return .join( chr(ord(c) - 0xFEE0) if 0xFF01 ord(c) 0xFF5E else c for c in s ) df[code] df[code].apply(to_halfwidth)全角转半角这个函数我用了很多年处理从某些系统导出的数据特别管用。原理是全角字符的编码和半角字符差了一个固定偏移量0xFEE0。5. 重复值与一致性检查5.1 重复值的识别重复值有两种完全重复和部分重复。完全重复就是整行一模一样用drop_duplicates直接搞定。部分重复是指业务主键重复但其他字段不同这种更麻烦需要业务判断保留哪一条。# 完全重复 df df.drop_duplicates() # 按业务主键看重复 dup df[df.duplicated(subset[order_id], keepFalse)] print(dup.sort_values(order_id).head(20))keepFalse会把所有重复的行都标出来方便你对比。我一般会先看看重复的样本长什么样再决定是保留最新的一条、还是保留信息最全的一条、还是合并。5.2 一致性检查的几个维度一致性检查是很多人会忽略的一步。我通常检查这几个方面同一实体的属性是否一致、跨字段的逻辑是否自洽、枚举值是否在预期范围内。比如同一个用户ID对应的性别出现了两种值这就是不一致。再比如订单的完成时间早于下单时间这就是逻辑不自洽。这些错误不检查出来后面分析结论就是错的。# 检查枚举值范围 valid_status [待付款, 已付款, 已发货, 已完成, 已取消] invalid df[~df[status].isin(valid_status)] print(f发现 {len(invalid)} 条非法状态值)提示一致性检查的规则最好写成配置文件或者常量列表而不是散落在代码各处。这样规则变更时只需要改一个地方。6. 数据标准化与编码6.1 什么时候需要标准化标准化不是必须的。如果你只是做统计描述不需要标准化。但如果你要做聚类、PCA、或者用基于距离的模型KNN、SVM那标准化就是必须的因为量纲差异会让大数值的变量主导距离计算。常见的标准化方法有两种Z-score标准化和Min-Max归一化。Z-score把数据变成均值0方差1适合数据近似正态的情况Min-Max把数据压到0到1之间适合有明确边界的情况。from sklearn.preprocessing import StandardScaler, MinMaxScaler scaler StandardScaler() df[[age_scaled, income_scaled]] scaler.fit_transform(df[[age, income]])这里有个坑要提醒fit_transform只能在训练集上用测试集必须用训练集的参数做transform。如果测试集也fit就造成了数据泄露模型评估结果会虚高。6.2 类别编码的选择类别型变量要转成数值才能喂给模型。最简单的是标签编码把每个类别映射成一个整数。但标签编码会引入不存在的顺序关系比如把红黄蓝编成1、2、3模型会以为蓝比红大。所以对于无序类别用独热编码更合适。但独热编码在类别数很多时会导致维度爆炸这时候可以考虑目标编码或者嵌入。# 独热编码 df pd.get_dummies(df, columns[city], prefixcity) # 标签编码 from sklearn.preprocessing import LabelEncoder le LabelEncoder() df[education_encoded] le.fit_transform(df[education])我个人的选择逻辑是类别数少于10个用独热10到50个考虑目标编码超过50个考虑嵌入或者直接放弃这个特征。7. 常见问题与排查技巧实录7.1 那些年踩过的坑我把预处理阶段最常遇到的问题整理成一张速查表都是实打实踩出来的。问题现象可能原因排查方法解决思路数值列变成object混入非数值字符pd.to_numeric(errorscoerce)看NaN数定位脏字符后替换或删除分组统计结果异常类别值有前后空格或大小写不一value_counts()看唯一值统一strip和lower日期转换报错格式不统一抽样看原始字符串先替换分隔符再指定format填充后分布畸变用了全局均值填非随机缺失对比填充前后分布改分组填充或模型填充模型效果虚高标准化时测试集也fit了检查fit调用位置只在训练集fit内存爆掉独热编码维度太高看编码后列数改用目标编码或降维7.2 几个独家避坑技巧第一个技巧永远保留一份原始数据副本。我习惯在脚本开头就df_raw df.copy()后面所有清洗都在副本上操作。这样一旦发现清洗逻辑有问题可以随时回到原点重新来不用重新读数据。第二个技巧清洗步骤写成函数链。不要把所有逻辑堆在一个大脚本里而是拆成一个个小函数每个函数只做一件事然后用管道串起来。这样调试的时候可以单独测每个函数出问题也好定位。def clean_missing(df): df[income] df.groupby(city)[income].transform( lambda x: x.fillna(x.median()) ) return df def clean_types(df): df[price] pd.to_numeric(df[price], errorscoerce) return df df df.pipe(clean_missing).pipe(clean_types)pipe这个用法我很喜欢它让清洗流程读起来像一条流水线非常直观。第三个技巧清洗前后都做一次数据快照。记录行数、列数、各列缺失率、关键统计量存成一个小报告。这样你能清楚知道每一步清洗影响了多少数据。如果某一步删掉了30%的行你就得停下来想想是不是逻辑有问题。7.3 关于性能的一点经验数据量大的时候预处理很容易成为瓶颈。我总结了几条能用向量化操作就别用applyapply本质是循环慢得离谱groupby之后尽量用内置聚合函数别用自定义lambda读取大文件时用dtype参数指定列类型能显著减少内存占用。dtypes {user_id: int32, amount: float32, city: category} df pd.read_csv(big_file.csv, dtypedtypes)把city这种低基数列指定为category类型内存能省一大截而且分组操作会更快。这个技巧在处理千万级数据时效果特别明显。数据预处理这件事说到底是一个不断做判断的过程。工具和代码都是次要的重要的是你清楚每一步为什么要这么做、这么做的代价是什么。我自己的习惯是每做完一步清洗都问自己一句这一步会不会引入新的偏差如果答案是可能会那就得想办法验证一下。清洗没有标准答案但有一套可以复用的思考框架这套框架才是真正值钱的东西。