
简介面向Python数据分析初学者与需要清理脏数据的Pandas使用者这份PDF以“数据预处理-1”为主题系统讲解缺失值的检测与处理。内容先介绍isnull、notnull等缺失值判断方法以及结合sum()统计缺失数量随后详细剖析dropna的axis、how、thresh、subset四个参数对删除行或列的影响并使用fillna演示0值替换、前向填充ffill、后向填充bfill及interpolate线性插值等补齐策略。每个操作均配有可直接运行的DataFrame示例数据字段包含姓名、年龄、工资、性别并提供全缺失行等边界情况便于读者对照理解不同参数的实际效果。资源共1个PDF文件大小375KB可作为配套教材的复习笔记按4.11节内容边看边练、快速上手。已有1349人浏览学习对正在夯实数据分析基本功的读者而言是一份聚焦且实用的参考资料。1. Python数据分析里的数据预处理通常决定项目是2小时还是2天Python数据分析里数据预处理不是“分析前的临时动作”它经常是整个项目周期里耗时最长、翻车率最高的环节。一次在处理促销活动数据时日期列是文本、金额里带着逗号和“元”字、同一个用户有多次重复记录直接 groupby 出来的月度销售曲线波动到没法看把类型、缺失值、重复值依次清理完曲线才恢复成可解释的形状。数据预处理从数据导入就开始一直延续到数据结构能支撑分析为止覆盖类型修正、缺失值、异常值、重复值、量纲和编码等一连串动作。这篇文章按实操推进顺序把每一步要调的参数、要做的判断和常见翻车点拆开讲。适合正在用 pandas 做数据分析、准备接手真实业务数据的分析师也适合想把目前流程补成可复用套路的一线开发者。2. 数据导入与探查先搞清楚文件里到底是什么2.1 读取参数就要配对编码、分隔符和空值标记一次摆平读 CSV 是数据预处理的起点也是奇怪报错最多的地方。不建议拿到文件直接df pd.read_csv(xxx.csv)就跑至少先回答三个问题文件是什么编码、分隔符是否是逗号、空值到底用哪种写法。国产 Excel 导出的 CSV 经常带 UTF-8 BOM直接读取时第一列列名会带上“\ufeff”代码看起来没报错但引用列时永远对不上。用encodingutf-8-sig能清掉这个 BOM。业务系统导出的大表可能是制表符分隔此时要填sep\t。还有些系统把空值写成“NULL”、“NA”或“\N”读取时不指定它们后续会被当成字符串导致数字列被读成 object。下面这个读取配置是我在处理这类文件时的常用写法import pandas as pd df pd.read_csv( raw_data.csv, sep,, # 记事本打开看一眼分隔符是逗号还是制表符 encodingutf-8-sig, # 应对 CSV 文件里的 BOM 头避免第一列列名出现 \ufeff na_values[, NA, NULL, \\N], # 把多种空值写法统一成 NaN low_memoryFalse # 大文件避免分块读取导致同一列 dtype 不一致 ) print(df.shape) print(df.columns.tolist())na_values这个参数容易忽略但作用很实际它把常见空值写法统一替换为 NaN后面所有缺失值判断就只用isna()这一个入口。low_memoryFalse则是为了类型一致pandas 默认分块读取时会对不同块做独立推断同一列在文件后半段可能被识别成不同 dtype后续做类型修正时非常被动。用内存换类型稳定在预处理阶段是划算的。如果数据在 Excel 里还有一个容易踩的点多个 sheet 不代表多个表很可能同一个事实表被切成几块。我的习惯是pd.read_excel(path, sheet_nameNone)先看全部 sheet 名再决定读哪几个而不是裸读第一个 sheet。2.2 用 info() 和 describe() 先做“预体检”不要急着 head()head()适合看表头长什么样但只看前几行很容易被表象骗过去。更可靠的做法是先执行一次全表体检df.info()输出每一列的非空计数和 dtypedf.describe(includeall)输出数值列的分位数和类别列的非空数量df.nunique()输出每列唯一值个数。三行命令组合跑完很多问题已经暴露出来了。# 展示每列非空情况、数据类型和内存占用 df.info(verboseTrue, show_countsTrue) # 数值列和类别列的统计信息一次看全 desc df.describe(includeall).T print(desc) # 每列唯一值数量用于快速定位 ID 列和低基数分类列 uniq df.nunique().sort_values() print(uniq)includeall很关键。describe()默认只描述数值列分类列的信息需要显式打开。show_countsTrue则把非空计数打出来配合isna()可以立刻看出哪一列缺失严重。实际项目里nunique()常常省掉不少误判用户ID 列不该被当成特征编码日期字符串看似唯一值高也可能是主键金额列的唯一值数如果异常低可能被四舍五入过或导入时丢了精度。这类陷阱靠这三个命令就能提前暴露。2.3 字段类型修正把日期文本和混着空值的数字拆开探查只是发现类型修正才是数据预处理的第一次动手。最典型的两个问题日期列被读成字符串金额列因为混入逗号或“元”字被读成 object。日期列不修正排序会按字符串字典序金额列不修正所有加总和分组统计都会跳过文本部分。我的做法是凡是语义上属于时间或数值的列一律显式转换不完全依赖 pandas 的自动推断。# 日期字段解析成 datetime解析不了的变成 NaT留到缺失值阶段处理 df[order_time] pd.to_datetime( df[order_time], format%Y-%m-%d %H:%M:%S, errorscoerce ) # 金额列先清理千分位逗号和单位再转数值 df[amount] df[amount].astype(string).str.replace(,, , regexFalse) df[amount] df[amount].str.replace(元, , regexFalse) df[amount] pd.to_numeric(df[amount], errorscoerce)format参数有讲究。如果不写死pd.to_datetime遇到“2023/01/02”“2023-01-02”“02-Jan-2023”混合格式时多数能解析出来但速度明显变慢更麻烦的是日/月互换格式如“03/04/2023”pandas 默认按英文习惯解析成 3 月 4 日。我的经验是只有格式未知且行数少时才交给推断一旦确认了业务导出的标准格式就把format写死。errorscoerce配合缺失值处理解析失败的位置会变成缺失标记等下一阶段统一处理比在报错里卡死要顺畅得多。金额列要先清洗再转换顺序不能反。如果直接对带逗号的字符串执行pd.to_numeric会在逗号处直接报错。代码链里的regexFalse是告诉 pandas 不要按正则处理逗号避免不必要的转义和性能损失。完成转换后再跑一次describe()核对 amount 列的分位数如果 max 比 q3 大出几个数量级下一步异常值处理就该启动了。3. 缺失值处理删、填还是保留缺失标记3.1 先看缺失模式再动手不同缺失机制对应不同策略缺失值处理没有标准答案但至少有一个标准流程先确认缺失分布再决定策略。动手填充之前我会先看两样东西每列缺失比例以及缺失是否集中在某个分组。比如支付金额在“app端”缺失率高在“pc端”几乎不缺失这就不是随机缺失而是渠道本身的采集逻辑差异。对这种结构性缺失简单填充甚至会强化错误信号一般考虑单独标记字段让模型自己学习缺失背后的含义。# 每列缺失率统计从高往低排序 miss_rate df.isna().mean().sort_values(ascendingFalse) print(miss_rate[miss_rate 0]) # 缺失是否集中在特定业务分组按渠道看金额缺失率差异 miss_by_seg ( df.groupby(channel)[amount] .apply(lambda x: x.isna().mean()) .sort_values(ascendingFalse) ) print(miss_by_seg)isna().mean()是统计缺失率最直接的写法isna()返回布尔 DataFrame布尔值的均值就是 True 的比例。groupby 里的apply回调接收一个 Series返回该组缺失率。如果某组缺失率和整体落差很大说明缺失原因大概率藏在分组键里接下来要把相关业务字段也一起纳入判断。基于缺失模式策略大致分三类。缺失比例很低且随机直接删除或均值填充影响都不大缺失比例中等且和其他字段有关联优先用分组填充或 KNN 参考多列缺失比例高且业务含义强不要盲目填出假数据保留“是否缺失”这个标记反而更有价值。数据预处理不是要把数据补齐到完美而是让分析在已知误差的前提下成立。3.2 填充方式的落地选择fillna、interpolate 和 KNN 怎么取舍落到具体填充方式我通常按下面这张表来选型。全局中位数只适合最简单的场景分组填充更能体现业务差异时间序列用插值类别字段则不要强行造一个平均类别。填充方式适用场景需要关注的参数常见副作用全局中位数/均值填充缺失率低、缺失随机fillna(value...)方差被压缩分布更尖峰分组中位数/均值填充不同业务组差异明显groupby().transform()分组键本身缺失时容易漏填时间序列插值带时间顺序的数据interpolate(method, limit_direction)连续缺失段超过 limit 时会填不满KNN 插补多列特征相互关联n_neighbors特征未标准化时距离会被大数主导落地代码长这样每个都是真实场景里的常用形式# 金额字段按渠道分组后填组内中位数dropnaFalse 保留分组键本身缺失的行 df[amount] ( df.groupby(channel, dropnaFalse)[amount] .transform(lambda x: x.fillna(x.median())) ) # 时间序列用前后相邻观测值线性插值 df[pm25] df[pm25].interpolate( methodlinear, limit_directionboth, # 序列开头和结尾也能延展 limit3 # 最多连续插 3 个空位防止延伸到完全无数据区 ) # 类别字段保留缺失标记而不是填一个不存在的“默认值” df[channel] df[channel].fillna(unknown) df[is_channel_missing] df[channel].eq(unknown).astype(int)第一段里dropnaFalse是防踩坑的关键。默认 groupby 会丢弃分组键为 NaN 的行组内填充自然不会作用在那几行这就是“填完还有空值”的经典来源。第二段interpolate的limit3是安全阀连续空值过多的数据段本来就不该靠插值硬猜限制插值长度等于控制猜测范围。第三段没有用数值去填类别原因很简单——平均值对分类变量没有业务解释。保留一个“缺失”水平对后续特征工程而言信息量反而更大。如果列之间关联确实紧密可以交给 KNNImputer它在多列同时缺失时也成立。但 KNN 要求各数值列先经过尺度统一否则距离会被量级大的列主导。所以我会把 KNNImputer 放在标准化之后使用或者用 pandas 先做完初步填充再进 KNN。用这套流程做下来我一般不会再出现“缺失值处理完反而更不准”的尴尬。4. 异常值、重复值和量纲清洗动作的边界在哪里4.1 用 IQR 和 Z-score 定位异常值先别急着删异常值判断有两个最常用的数学门槛Z-score 和四分位距。Z-score 的思路是假设数据接近正态分布偏离均值三个标准差以上的点算异常IQR 思路是从分位数出发超过四分位距 1.5 倍的范围才标记。两者的代码都不长但真正的坑在于定位之后不要直接删除。import numpy as np # Z-score偏离均值超过 3 个标准差的记录 mean_val df[amount].mean() std_val df[amount].std() df[amount_z] np.abs((df[amount] - mean_val) / std_val) print(df.loc[df[amount_z] 3].sample(3)) # IQR超过四分位距 1.5 倍的范围 q1 df[amount].quantile(0.25) q3 df[amount].quantile(0.75) iqr q3 - q1 lower, upper q1 - 1.5 * iqr, q3 1.5 * iqr print(df.loc[~df[amount].between(lower, upper)].sample(3))注意这两种方法对数据量级的敏感度差别。Z-score 对均值和标准差都不稳健偏态分布下均值被少数大值拉高正常偏低的值反而会被误报IQR 用分位数对长尾分布更稳定但它只考虑单列分布不会结合业务含义。数据预处理阶段真正要做的是把可能异常的值列出来观察它们是否和业务规则冲突。比如订单金额超过 1 万对普通零售是异常对批发渠道可能是常态直接删掉会让统计结论失真。我习惯多做一步给异常值加一个标记列而不是直接删行。标记列的好处是可以随时检验删除影响等于给自己留一颗后悔药不用因为一刀切导致后面分析无法解释。4.2 重复值去重不是无脑 drop_duplicatessubset 和 keep 怎么用重复数据处理的第一直觉是“整行完全一致才算重复”现实中往往还有两种情况主键相同但其他列有差异以及不同主键指向同一业务实体。drop_duplicates()的subset和keep就是控制这两类场景的关键参数。# 场景一整行完全重复 df.drop_duplicates(inplaceTrue) # 场景二order_id 是业务主键保留首次出现的记录 df.drop_duplicates(subset[order_id], keepfirst, inplaceTrue) # 场景三同一个 user_id 重复注册保留最近一次操作对应的记录 df ( df.sort_values(created_at) .drop_duplicates(subset[user_id], keeplast) .sort_index() )场景一有一个容易被忽略的细节如果两个完全相同行之间差一个空格或一个大小写drop_duplicates也视为不同记录。所以对文本列先str.strip()再去重能避免同一客户因为空格被当成两行。场景三的顺序不要写反必须先排序再利用keeplast保留最后一条否则排序没生效去重结果就不可控。去重后还要核对数量而不是只看 shape 变化。# 核对本轮去重到底删了多少行有没有误伤非重复数据 before len(df) print(df.duplicated(subset[user_id]).sum()) df df.drop_duplicates(subset[user_id], keeplast) after len(df) print(fremoved: {before - after})duplicated().sum()算出来的是待删除行数removed应该和它一致。如果不一致说明排序或keep的选择造成额外行被隐去需要回头检查sort_values是否真的生效。4.3 标准化、归一化和类别编码模型选型决定预处理配方量纲处理的作用比很多人以为的更依赖模型。决策树和基于树的集成基本不受特征缩放影响因为分裂点是绝对值而不是距离但线性回归、逻辑回归、SVM、KNN 这些依赖距离或梯度的算法量纲差异会直接扭曲结果。要不要做标准化由模型选型决定而不是约定俗成。from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.compose import ColumnTransformer # 一个把数值和类别统一处理的组合器 prep ColumnTransformer( transformers[ (num, StandardScaler(), [amount, quantity, score]), (cat, OneHotEncoder(handle_unknownignore), [channel, city]), ], remainderdrop ) X_processed prep.fit_transform(df)这段代码里最重要的参数是handle_unknownignore它保证训练阶段没出现过的类别在测试或上线时不会直接报错而是把新类别对应的列全部置为 0。业务侧需要接受这个行为新类别没有历史编码只能靠模型对全 0 特征做预测。remainderdrop则把没在 transformers 里列出的列全部丢弃如果只想保留指定的特征集这样很干净想保留剩余列时可以把remainder改成passthrough。标准化里的均值和标准差只应该从训练集拟合之后测试集、上线环境都复用同一套统计量。这个细节直接关系到第 5 章说的数据泄漏问题。在整个数据预处理实践里把标准化和编码挂在同一个预处理对象里是最值得早点养成的一个习惯——后面换模型、上 pipeline 都不用回改数据准备代码。5. 数据预处理避坑手册五条翻车记录和排查顺序5.1 数据行数锐减模型训练却说特征不够现象清洗环节做完记录从 10 万掉到 6 万后续跑模型发现部分类别完全消失准确率也明显下降。原因多半是无意识的df.dropna(inplaceTrue)只要某一行在任意一列存在 NaN整行就被删除。现实中的缺失往往带业务倾向高频缺失的字段并不代表样本无效简单删行会连正常样本一起牺牲。解决先看每列缺失率再决定方向能用缺失标记列就尽量避免删行。如果一定要删除用subset明确指定关键列例如df.dropna(subset[order_id, channel])避免一删一大片。5.2 分组填充完仍有空值groupby 默认丢弃了缺失分组键现象执行df.groupby(channel)[amount].transform(lambda x: x.fillna(x.median()))后检查发现仍有一部分 amount 是 NaN。原因默认参数dropnaTruepandas 在 groupby 时会把 channel 为空的行排除出去transform 也就不会对那几行计算填充值。这类问题的典型特征是剩余空值都集中在 channel 本身为 NaN 的行上。解决给 groupby 增加参数dropnaFalse让 channel 为 NaN 的行也进入分组并参与填充。如果业务上不允许凭空分到一个组就先用unknown替代 channel 的缺失再填 amount。5.3 训练集高分、线上分数崩预处理统计量泄漏测试信息现象训练验证准确率很高上线后对真实新数据的预测效果明显变差。原因在切分训练集测试集之前就直接对整个数据集做了标准化或均值填充。标准化时的均值和标准差把测试集的信息也算进去了模型等于提前偷看了未来的数据这叫数据泄漏。填充均值时同样如此。解决先用train_test_split把训练集测试集分开再在训练集上fit测试集上只用transform复用训练出的统计量。更省心的做法是直接放进 sklearn Pipeline标准化的参数会自动只在fit阶段计算。我在模型上线前还会把每列均值、标准差落成 JSON 文件线上推断时加载同一份参数避免两套环境算出不同的缩放结果。5.4 日期字段排序错乱字符串日期和时区混用现象按df.sort_values(order_time)排序后第一行不是预期里最早的记录甚至“2024-01-05”排在“2023-12-20”之前。原因order_time 列没有转成 datetime排序时按字符串字典序逐位比较年、月、日之间被切分得毫无逻辑。部分数据库导出还以 UTC 存储直接展示时与本地时间差 8 小时情况更乱。解决尽早统一为 datetime 并明确时区。pandas 用pd.to_datetime(df[order_time], utcTrue)先完成解析再用dt.tz_convert(Asia/Shanghai)转到业务时区。如果数据源既有带时区的字符串也有不带时区的字符串要先统一格式再合并不然混着比较最容易出问题。5.5 用 Z-score 处理偏态长尾数据把正常记录误报成异常现象用 Z-score 阈值设为 3 筛选 amount结果一大部分订单都被标记为异常删除后统计指标反而失真。原因金额类数据通常是右偏的长尾分布均值被少数大额记录拉高Z-score 以均值和标准差为基准对偏态分布并不合适。大额正常订单全部超过 3 个标准差小额正常订单又被压缩到难以区分。解决先做对数变换np.log1p(amount)让分布更接近正态再用 Z-score或者直接改用 IQR因为它是基于分位数的对长尾更稳。也可以加业务阈值比如只把金额低于 5 元或高于渠道平均价 30 倍的记录视为可疑。数据预处理里最忌讳把统计指标当成黑匣子盲目使用每个公式都要问一句它背后的分布假设是什么当前数据真的满足吗。6. 把预处理固化成一个可复现的流水线我现在的写法6.1 每个清洗动作封装成函数用 pipe 串起来分析做完一轮后回头重构代码是收益远超代价的阶段。把清洗动作写成函数输入 DataFrame输出处理后 DataFrame再通过pipe串起来每一步都能单独测试后面接入新数据源只要格式一致调用顺序直接复用。def clean_dates(df, date_cols): for col in date_cols: df[col] pd.to_datetime(df[col], errorscoerce) return df def fill_amount_by_channel(df): df[amount] df.groupby(channel, dropnaFalse)[amount].transform( lambda x: x.fillna(x.median()) ) return df def dedup_by_key(df, key): return df.sort_values(created_at).drop_duplicates(subsetkey, keeplast) df_clean ( df.copy() .pipe(clean_dates, [order_time, pay_time]) .pipe(fill_amount_by_channel) .pipe(dedup_by_key, [order_id]) )每个函数都保持“一进一出”的边界pipe 链条相当于一张可见的清洗路线图。需要排查时把链条从中间断开分别观察前半段和后半段的 shape 与样例不动其他代码就能定位问题。参数也落得很直白date_cols用 list 传后续新增日期列时不用改函数体dedup_by_key的 key 是列表多字段主键只改一行调用即可。我每次还会拿一个只有几行数据的小样例单独测试clean_dates确认日期转换在异常输入下表现符合预期再把它接进主流程。6.2 用 ColumnTransformer 把清洗和建模装进同一个 Pipeline对要直接进模型的特征我习惯把类型转换和编码交给 sklearn 管线原因是 scikit-learn 的 fit/transform 语义天然防止数据泄漏。下面的结构是我目前项目里的常用骨架from sklearn.compose import ColumnTransformer from sklearn.preprocessing import OneHotEncoder, StandardScaler from sklearn.pipeline import Pipeline from sklearn.linear_model import LogisticRegression preprocessor ColumnTransformer( transformers[ (num, StandardScaler(), [amount, quantity]), (cat, OneHotEncoder(handle_unknownignore), [channel, city]), ], remainderdrop ) pipe Pipeline(steps[ (prep, preprocessor), (clf, LogisticRegression(max_iter1000)), ])pipe.fit(X_train, y_train)执行后标准化的均值和标准差、OneHot 的类别编码都只在训练集上确定。pipe.predict(X_test)会自动复用训练阶段的 preprocessor 结果不会重新计算。这也意味着后续上线时可以把整个管道保存下来直接用joblib.dump(pipe, pretrain_pipeline.joblib)运行时加载后直接 predict省掉线上单独重建预处理逻辑的麻烦。我现在的习惯是每次新建分析工程先写管道的骨架再把数据处理动作逐个填进函数而不是先在 notebook 里贴几十行临时脚本。这样哪怕进度被打断第二天打开还能按原计划重建现场。文章里这些步骤从数据导入探查到缺失值、异常值、重复值再到编码和量纲覆盖了我做 Python 数据分析项目时最常碰到的预处理动作。多数行数问题不是靠小心堆参数解决而是靠固定流程希望这个写法对你有帮助也希望你下次遇到预处理报错时能更快判断是数据问题还是方法问题。本文还有配套的精品资源点击获取