ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Pandas DataFrame缺失值处理全攻略:从检测到填充的完整实践

Pandas DataFrame缺失值处理全攻略:从检测到填充的完整实践 做数据分析尤其是真正跑项目的朋友一定有过这种体验拿到的数据表干干净净、一行不缺一列不少的情况几乎不存在。绝大多数时候第一步面对的就是各种NaN、None、空白字符串甚至还有伪装成正常值的异常占位符。这个系列到第47篇我想把DataFrame缺失值处理这件事彻底讲透。很多初学者一见到NaN就dropna()全删了我见过太多项目因为这一步图省事结果后面模型效果稀烂回头查才发现是样本量被砍了一半或者分布被彻底扭曲。缺失值处理不是简单的“删”或“填”它是一套需要结合业务场景、数据分布、后续建模需求来做综合决策的流程。这篇东西我尽量不写废话直接把我在实际项目里怎么检测缺失值、怎么判断类型、怎么选处理策略、怎么填、怎么插值以及填完之后怎么验证效果完完整整走一遍。适合刚入门pandas但想做正经数据分析项目的同学也适合已经写了不少代码、但遇到缺失值还是凭感觉处理的进阶选手。我会用一份模拟的电商订单数据作为贯穿全文的案例所有代码都是可以直接复制跑的有坑的地方我会重点标注。1. 为什么说缺失值处理是数据分析项目的“生死线”1.1 缺失值对后续分析的三重打击先说直接的后果。第一重打击是统计描述失真。你算均值、方差、相关性矩阵时pandas默认会忽略NaN这本身没什么问题但如果某个字段缺失率高达40%剩下的60%样本还能代表整体吗很可能不能。因为数据缺失往往不是随机的有缺失的样本和无缺失的样本本身就可能存在系统性差异这叫缺失机制后面我会细说。第二重打击是特征工程被破坏。做特征衍生的时候比如你要构造“用户平均客单价”这个特征如果订单金额有缺失你可能用总数除以非缺失数算出来的结果会系统性偏高。再比如你要做分箱、做One-Hot编码缺失值如果不处理sklearn直接报错被迫提前删掉大量样本或列。第三重打击是模型训练直接崩。线性回归、逻辑回归、SVM这些模型输入矩阵里不能有NaN否则数值计算会出问题。虽然XGBoost、LightGBM这类树模型号称能自带缺失值处理但只要看过源码或实际跟踪过分裂过程就知道它们内部也有默认方向的选择逻辑如果缺失比例高这个隐式处理会引入偏向性。我在一个信用评分项目里就栽过跟头——LightGBM默认把缺失值分到增益最大的方向结果线下验证指标明明不差上线后人群偏移严重因为线下数据的缺失模式跟线上真实场景不一致。1.2 缺失机制完全随机、随机、非随机做数据分析的人很少去查统计学的缺失机制定义但这三个概念直接决定了你该用删除还是填充。MCAR完全随机缺失指某个字段的值是否缺失跟任何其他变量以及自身取值都无关纯粹是随机事件比如数据录入时漏了一条、采集设备偶尔故障。这种情况是最“幸运”的因为剩下的样本仍然无偏你直接删掉缺失行对整体分布影响很小。MAR随机缺失指缺失概率跟其他已观测变量有关但跟自身未观测的取值无关。比如男性更倾向于不填收入字段收入和性别相关但我们知道性别。这种情况下如果你只用收入非缺失的样本来做分析样本里男性比例就会偏高这就是选择性偏差需要靠填充或用其他变量建模来修正。MNAR非随机缺失指缺失概率跟自身取值相关。比如收入特别高的人故意不填收入、严重故障的设备测出的关键指标直接不输出。这是最麻烦的情况任何简单的删除或填充都可能产生严重偏差经常需要领域知识介入甚至要把“是否缺失”本身当作一个特征喂给模型。现实中绝大多数数据是MAR和MNAR的混合体。这也是为什么我强调不分析缺失原因就动手删填等于闭着眼睛开车。2. 动手之前先把DataFrame里的缺失值彻底摸清楚2.1 缺失值检测的基本姿势说到检测很多人的第一反应是df.isnull().sum()这没错但远远不够。我先列一套比较完整的检测方案然后再解释为什么这样组合。import pandas as pd import numpy as np # 生成一份模拟电商订单数据 np.random.seed(42) n 2000 df pd.DataFrame({ order_id: range(10000, 12000), user_id: np.random.randint(1001, 3000, n), age: np.random.randint(18, 70, n).astype(float), gender: np.random.choice([M, F], n), order_amount: np.round(np.random.lognormal(4.5, 1, n), 2), order_time: pd.date_range(2024-01-01, periodsn, freqmin), coupon_discount: np.random.choice([0, 5, 10, 20, 30], n, p[0.6, 0.2, 0.1, 0.07, 0.03]), delivery_time: np.random.randint(1, 7, n).astype(float) }) # 人为制造缺失 missing_idx np.random.choice(n, size150, replaceFalse) df.loc[missing_idx, age] np.nan missing_idx2 np.random.choice(n, size300, replaceFalse) df.loc[missing_idx2, order_amount] np.nan df.loc[np.random.choice(n, size80, replaceFalse), delivery_time] np.nan df.loc[np.random.choice(n, size50, replaceFalse), gender] None df.loc[np.random.choice(n, size30, replaceFalse), coupon_discount] -1 # 伪装缺失 # 基础检测 print(df.isnull().sum()) print(df.isna().mean().round(4) * 100) # 缺失率百分比isnull()和isna()完全等价一个是SQL风格命名一个是统计风格命名你习惯用哪个都行。输出缺失率百分比这个操作容易被忽略但它其实特别重要——缺失率低于1%的字段和缺失率30%的字段处理策略完全不是一个量级。但还有个细节很多人会漏NaN只是pandas里最标准的缺失标识实际业务数据里的缺失往往穿着各种马甲比如空字符串、特殊占位符-1、9999、NULL、Unknown。上面代码里我把coupon_discount的部分值填成了-1这就是典型的伪装缺失。你如果只跑isnull()根本发现不了。所以有一道很重要的工序叫“缺失值审查”# 检查各列的特殊占位符和空字符串 for col in df.columns: if df[col].dtype object: # 找出空字符串、空格字符串、常见占位符 special df[col].isin([, , NULL, null, None, nan, NaN]).sum() if special 0: print(f{col}: 发现{special}个特殊占位符) else: # 数值列检查是否出现业务上不可能的值 neg_count (df[col] 0).sum() if neg_count 0: print(f{col}: 发现{neg_count}个负数) # 统一把伪装值转成NaN df[coupon_discount] df[coupon_discount].replace(-1, np.nan) df.replace({: np.nan, : np.nan, NULL: np.nan, null: np.nan}, inplaceTrue) print(df.isnull().sum())这一步能在项目早期拦住大量脏数据。我在真实项目里遇到过客户表里“年龄”字段有200多个-1后来才知道是前端表单默认值业务上就是不详。这种伪装缺失不改写成NaN后面做任何统计都会把-1当成真实值参与计算均值直接被拉低5岁。2.2 缺失值可视化一眼看出缺失模式数量级搞清楚之后我建议再做一步可视化尤其当数据列数比较多的时候。Python生态里有个专门干这个的库叫missingno装一下就能用pip install missingno matplotlibimport missingno as msno import matplotlib.pyplot as plt # 矩阵图每行是一条样本每列是一个字段白线表示缺失位置 msno.matrix(df, figsize(12, 6)) plt.show() # 相关性热图看字段之间缺失是否相关 msno.heatmap(df, figsize(8, 6)) plt.show() # 排序柱状图 msno.bar(df, figsize(10, 4)) plt.show()msno.matrix输出的是样本维度的缺失分布图如果白线在某个区域扎堆说明这些样本缺失集中在特定时间段或特定用户群这本身就是重要业务线索。msno.heatmap算出的是字段之间的缺失相关性比如age缺失的行里order_amount也经常缺失那这两个字段的缺失可能同源处理时可以合并考虑。这一步往往能帮你发现手工数数发现不了的结构性问题。我给一个真实例子某次做用户画像job和income两个字段缺失率分别是35%和28%msno.heatmap显示相关系数0.8以上说明失业或自由职业人群大概率同时不填这两项。这时候如果分别填充等于把“从事低收入或不稳定职业”的群体强行估计成平均收入分析结论肯定偏。3. 删除策略不是所有缺失都值得你费力抢救3.1 dropna的完整参数拆解删除是最简单粗暴的方案但要删除得有章法。dropna有几个参数每一个都有讲究# 默认参数删除任何含有NaN的行 df_clean df.dropna() # 只删除指定列全为NaN的行本例中无效因为没有整行全空的 df_clean df.dropna(howall) # 只对指定的列检查缺失删掉这些列上有NaN的行 df_drop_age df.dropna(subset[age]) # 非NaN数量至少为4才保留该行 df_clean df.dropna(thresh4) # 横向删除按列删只要这一列有缺失就删整列 df_clean df.dropna(axis1)howall只在整行全部字段都是NaN时才删适合处理采集失败产生的完全空记录对普通情形用处不大。subset是最常用的参数意思是只要指定列有缺失就删掉那一行。我通常建议用subset而不是全表dropna因为不同列的缺失严重程度不一样一刀切全删数据量可能直接崩掉。thresh参数是保留“非缺失字段数不少于某个阈值”的行适合那种一行里只要关键字段够多就留着的场景常用于处理问卷数据。还有一个常见误区是inplaceTrue。我的建议是永远不要用。这东西在pandas老版本里是内存优化手段但现在链式写法更清晰、更不容易出bug。你直接df df.dropna(...)重新赋值就行inplaceTrue在pandas未来的版本里都打算废弃了。3.2 什么情况下应该考虑删除而不是填充删除会降低样本量所以必须有个明确标准。我给一个自己项目的经验法则不一定是最优解但至少是个不犯错的底线第一缺失率低于5%且样本总量足够大的时候dropna(subset[某关键列])是个舒服的选择。比如你有100万行某字段缺3万行删掉对这列的分析完全无伤大雅还省了填充可能引入的偏差。第二缺失值集中在某个你根本不会用到的字段那直接删列或删行都行别浪费精力。第三缺失机制是MNAR且缺失比例高如果领域经验告诉你填了也是瞎填删除并明确记录“该字段有效样本量”反而比胡乱填充更诚实。但凡是下面这些情况删除就要非常谨慎了样本量本来就小比如只有两三百条删完统计功效不足缺失不是随机出现删行会引入选择性偏差该字段是模型的核心特征删光后模型直接哑火。3.3 删除操作的注意事项删行之后要立刻检查结果尤其是索引。pandas删行后索引会保留原来的编号出现很多“空洞”这在后续merge、join、groupby的时候会产生一些莫名其妙的边界行为。我的习惯是操作完就跑一句df.reset_index(dropTrue, inplaceTrue)把索引重新整理成连续的这样最省心后面调试也不会犯糊涂。df_drop df.dropna(subset[age]).reset_index(dropTrue) print(f删除前样本量: {len(df)}删除后样本量: {len(df_drop)}) print(f删除比例: {(len(df) - len(df_drop)) / len(df) * 100:.2f}%)这个“删除比例”建议打印出来贴在分析报告里作为数据预处理记录的一部分。数据清洗的过程要可追溯不然三个月后你自己回来看代码都会忘记当时为什么少了300行数据。4. 填充策略fillna的几套进阶打法4.1 统计值填充均值、中位数、众数的选择逻辑初学者最爱干的事就是fillna(df[col].mean())或者fillna(0)方便是方便但先别急着写。填充值的选择背后是有逻辑的。对于数值型字段均值填充的前提是数据近似对称分布、没有极端异常值。只要数据分布是右偏的比如收入、订单金额、房价这类你永远能看到几个大到离谱的土豪订单均值就会被极端值拉高你一填充相当于给缺失样本人为注入了一个偏高的估计后面做统计必然虚高。这种情况用中位数更稳健。我个人的习惯是先画个直方图看一眼分布形态再做决定。众数填充主要针对分类变量比如性别、城市、支付方式。但用众数填充分类变量有一个副作用——它会强化该字段原有的分布倾斜。如果原有性别比例是8:2你把缺失的性别全填成比例高的那边缺失部分会把这个8:2进一步推向9:1甚至更高。所以当分类字段缺失率较高的时候我更推荐单独建一个“未知”类别而不是硬猜。# 数值列按分布选择填充值 df[age_filled_mean] df[age].fillna(df[age].mean()) df[age_filled_median] df[age].fillna(df[age].median()) # 订单金额明显右偏用中位数更稳 print(forder_amount均值: {df[order_amount].mean():.2f}) print(forder_amount中位数: {df[order_amount].median():.2f}) df[order_amount_filled] df[order_amount].fillna(df[order_amount].median()) # 分类列填充众数 df[gender_filled_mode] df[gender].fillna(df[gender].mode()[0]) # 分类列干脆新增未知 df[gender_filled_unknown] df[gender].fillna(未知)4.2 分组填充比全局填充高明一截实战里我最常用的是分组填充。道理很简单——不同用户群体的特征差异太大全局用一个数填充等于抹平了群体差异。比如age有缺失但你手里有user_level用户等级完全可以按用户等级分组各组的年龄中位数分别填充。# 给样本模拟一个用户等级列 df[user_level] np.random.choice([普通, 黄金, 铂金, 钻石], n, p[0.5, 0.3, 0.15, 0.05]) # 先看看各等级用户年龄差异 print(df.groupby(user_level)[age].median()) # 分组填充 df[age_filled_group] df.groupby(user_level)[age].transform(lambda x: x.fillna(x.median())) print(f分组填充后的年龄均值: {df[age_filled_group].mean():.2f})transform的作用是按分组计算出一个与原始DataFrame等长的序列这样fillna才能正确地逐行对齐。如果不加transform直接groupby再fillna因为索引对不齐大概率会报错。这个错我当年也踩过所以重点提一下。还有一种更实用的操作用时间维度做分组。很多业务数据有月份、星期的周期性比如电商客单价周末和工作日差异很大缺失的订单金额如果按整周平均填充必然失真。按星期几分组填充效果会好很多。关于分组填充还有一个重要的前置动作groupby里如果分组依据的字段本身也有缺失分组时会自动忽略这些样本它们没法被填充这时你要先处理分组字段的缺失再处理目标字段的缺失。4.3 前后向填充与时间序列场景methodffill和methodbfill是时间序列和面板数据里的保留节目。前者用上一条非缺失值填充后者用下一条非缺失值填充。典型场景是传感器采集设备10点报数正常11点断连12点恢复那11点的值用10点的值顶上就比用全序列均值合理得多。# 构造一个带缺失的时序数据 ts pd.Series([1.0, np.nan, np.nan, 4.0, np.nan, 6.0]) print(ts.ffill()) # 0 1.0 # 1 1.0 # 2 1.0 # 3 4.0 # 4 4.0 # 5 6.0 print(ts.bfill()) # 0 1.0 # 1 4.0 # 2 4.0 # 3 4.0 # 4 6.0 # 5 6.0但要注意ffill有个隐藏风险序列开头连续缺失时ffill无能为力会保留NaNbfill同理处理序列结尾的缺失。实战中我会组合用先ffill再对剩余的NaN用bfill或者反过来尽可能把边界缺失也覆盖掉。另外还有一个容易被忽略的需求limit参数。有些场景下你不想让缺失值被一路填充到底。比如物联网设备断电了3个小时中间的缺失值你用上一小时的数据推能接受但如果断了一整天你还一路填充那就是在制造假数据。这种场景可以限制最大填充长度# 最多填充2个缺失位置再多就保持NaN ts_filled_limited ts.ffill(limit2) print(ts_filled_limited)这会输出[1.0, 1.0, 1.0, 4.0, 4.0, 6.0]——注意索引2的位置在ffill最多填充两格的限制下它无法被索引0的1.0填充距离两格也不会用索引3的4.0回填因为走的是ffill方向所以索引2保持NaN。这种对边界的精确控制在真实生产链路里特别重要。4.4 用字典精准填充不同列还有一种少有人提但非常方便的写法直接传一个字典给fillna每列用不同的填充策略。# 各列填充规则不同 fill_rules { age: df[age].median(), gender: 未知, order_amount: df[order_amount].median(), delivery_time: df[delivery_time].mode()[0] } df_filled df.fillna(fill_rules) print(df_filled.isnull().sum())这个写法比逐列fillna然后反复赋值清爽很多而且规则一目了然方便以后排查。它内部的逻辑就是pandas遍历字典的键把对应的列单独填充。我强烈建议把这个字典作为数据清洗文档的一部分保留下来写到注释或配置文件里因为它是所有数据预处理的“决策依据”。5. 插值方法让填充值更“顺滑”的高级操作5.1 interpolate的线性、多项式与样条插值fillna属于静态填充无论均值、中位数还是前后填充填出来的值都是常数或临近值。但有些数据是有趋势的比如用户增长曲线、商品销量走势、股票价格。这种时候用interpolate()做插值会让填充值看起来更像是真实序列的一部分。# 构造带缺失的序列 s pd.Series([10, 12, np.nan, 16, 18, np.nan, np.nan, 24, 26]) print(s.interpolate()) # 线性插值 # 0 10.0 # 1 12.0 # 2 14.0 # 3 16.0 # 4 18.0 # 5 20.0 # 6 22.0 # 7 24.0 # 8 26.0 # 更平滑的样条插值 s_spline s.interpolate(methodspline, order3) print(s_spline)线性插值的逻辑很简单两个已知点之间连一条直线缺失值按位置比例分配到直线上。上面例子里12和16之间缺一个数取中间值14。样条插值则是用一个平滑曲线去拟合整段数据然后取缺失位置对应的曲线值结果更自然但也要小心过拟合的问题尤其数据本身噪声很大的时候。有一个关键参数limit_direction控制插值方向。默认forward是只往缺失位置的后方插值both表示前后都插backward则是往前。如果你希望序列首尾的缺失也处理掉需要显式设置limit_directionboth。# 首尾缺失也处理 s2 pd.Series([np.nan, 12, np.nan, 16, 18, np.nan, np.nan, 24, np.nan]) print(s2.interpolate(limit_directionboth))5.2 时间序列插值methodtime如果索引是时间类型线性插值默认按位置等距处理但真实时间序列里两个观测点之间的时间间隔往往不一样。这时候用methodtimepandas会按实际时间间隔做线性插值结果更符合时间语义。# 构造不规则时间索引 ts_index pd.to_datetime([ 2024-01-01 00:00:00, 2024-01-01 00:10:00, 2024-01-01 00:20:00, 2024-01-01 00:50:00, # 间隔0.5小时 2024-01-01 01:50:00 # 间隔1小时 ]) ts_data pd.Series([1.0, np.nan, 3.0, np.nan, 2.0], indexts_index) # 按时间间隔插值 vs 按位置插值 print(按时间间隔:, ts_data.interpolate(methodtime).round(3).tolist()) print(按位置:, ts_data.interpolate().round(3).tolist())按时间插值时00:10这个位置在1和3之间时间间隔正好是10分钟线性估算约等于2而00:50在3和2之间占整段间隔的3/5算出来接近2.67。如果按位置插值00:50会被当作4个位置中的第3个位置比例是2/4算出来接近2.5。两种结果差了0.17在高精度预测场景里就是不可忽略的误差。5.3 用回归模型和KNN填充缺失值插值方法处理完序列数据后还有一个进阶思路把缺失值当作预测目标用其他字段来预测它。这类方法统称为“基于模型的填充”。最经典的两种是sklearn.impute.SimpleImputer和KNNImputer。SimpleImputer是最小封装本质上就是统计学填充的sklearn版但它的价值在于可以直接放进pipeline里让填充操作成为机器学习流程的一部分训练集和测试集用同样的规则避免数据泄露。这个细节很多人忽略如果你在训练集上算好均值再拿这个均值去填测试集那没问题但如果你把训练集和测试集混在一起算均值再分开信息就泄露了。KNNImputer的思路更巧妙它看一个样本的K个最近邻用这些邻居在该字段上的取值来估计缺失值。需要说明的是KNN填充计算的是特征空间的“距离”如果特征尺度差异大必须先做标准化否则量纲大的特征会主导距离计算影响填充效果。from sklearn.impute import SimpleImputer, KNNImputer from sklearn.preprocessing import StandardScaler # 准备数值特征先标准化再KNN填充 features df[[age, order_amount, delivery_time]].copy() features_scaled StandardScaler().fit_transform(features) imputer_knn KNNImputer(n_neighbors5) features_filled imputer_knn.fit_transform(features_scaled) # 逆标准化还原 df_knn_filled pd.DataFrame(features_filled, columnsfeatures.columns) print(df_knn_filled.head())KNN填充的好处是它考虑了多个变量的联合分布比单变量的均值填充信息量大很多。缺点是计算复杂度高大数据集上会比较慢而且如果特征本身缺失比例就高KNN找到的邻居可能本身也缺那只能继续用邻居的邻居的信息误差会叠加。我还想提一个建模派很喜欢的思路直接把is_missing标记作为新特征。很多时候“某个字段缺失”这个事实本身就有预测能力。例如信贷场景里申请人“工作单位电话”这一栏缺失往往意味着职业稳定性差违约率更高。所以我会在建模型前给缺失字段增加一个布尔特征df[order_amount_missing] df[order_amount].isna().astype(int) df[age_missing] df[age].isna().astype(int)这个特征在XGBoost这类树模型里经常能排进特征重要性前十。不要觉得多此一举实际效果往往好于你精确填充出来的值。6. 完整实战从检测到决策的一条龙流程6.1 需求背景与数据集说明为了把前面这些方法串起来我准备走一个完整的实战流程。假设现在是2024年一季度你拿到了一份电商平台订单表共2000行字段包括订单ID、用户ID、年龄、性别、订单金额、下单时间、优惠券折扣、配送时长、用户等级。你的任务是做一份用户消费行为分析报告重点包括不同年龄段的消费差异、订单金额分布、配送时效对复购的影响。先看数据情况# 缺失概览 print(df.isnull().mean().round(4) * 100)从前面的构造可以推断年龄缺失率7.5%订单金额缺失率15%配送时长缺失率4%性别缺失率2.5%优惠券折扣有2.5%伪装成-1的缺失。用户等级无缺失订单ID和用户ID无缺失。6.2 分字段制定处理策略按照我的习惯拿到缺失报告后先画矩阵图和热力图再按字段逐一决策。age缺失率7.5%且根据业务经验老年人填写年龄的意愿低属于MNAR倾向。但年龄又是分析报告的关键维度所以不能简单删除。方案按用户等级分组的中位数填充同时生成age_missing标记列。为什么按组填充因为用户等级和年龄高度相关钻石用户普遍比普通用户年长不同组的年龄中位数差异显著实际代码可以验证。order_amount缺失率15%这是本次分析的核心字段。如果直接删掉15%的行样本量降到1700损失有点大。而且这部分缺失可能和支付方式、异常订单有关。方案先检验订单金额的分布确认明显右偏后用中位数填充。同时生成order_amount_missing标记列后续做消费分层时可以考虑把填充值和标记列同时纳入。delivery_time缺失率4%是模型里的次要特征。方案用众数填充即可。为什么不是中位数配送时长是整数计数类型的离散变量众数能保证填充值在现实可能的取值范围内。填中位数可能填出3.5这种不存在的配送时长。gender缺失率2.5%样本量足够大而且性别对分析结果影响有限。方案直接删除性别缺失的行用subset[gender]做条件删除。这样比填充众数更干净也不引入偏差。coupon_discount伪装成-1先替换成NaN再按业务规则填充。优惠券折扣为NaN通常意味着用户没有使用优惠券折扣为0所以直接填0是符合业务含义的。6.3 完整代码与过程记录下面把整个处理流程写成一个干净的脚本每一步都留下记录方便复核import pandas as pd import numpy as np # 1. 统一识别缺失 df.replace({: np.nan, : np.nan, NULL: np.nan, -1: np.nan}, inplaceTrue) # 2. 缺失率报告 missing_report pd.DataFrame({ 缺失数: df.isnull().sum(), 缺失率: df.isnull().mean().round(4) }) print(处理前缺失报告) print(missing_report) # 3. 处理性别删除 df df.dropna(subset[gender]).reset_index(dropTrue) # 4. 处理优惠券折扣业务填充为0 df[coupon_discount] df[coupon_discount].fillna(0) # 5. 处理年龄按用户等级分组中位数填充 缺失标记 df[age_missing] df[age].isna().astype(int) df[age] df.groupby(user_level)[age].transform(lambda x: x.fillna(x.median())) # 6. 处理订单金额先验证分布再中位数填充 缺失标记 import matplotlib.pyplot as plt df[order_amount].hist(bins50) plt.title(order_amount分布填充前) plt.show() print(forder_amount偏度: {df[order_amount].skew():.2f}) df[order_amount_missing] df[order_amount].isna().astype(int) df[order_amount] df[order_amount].fillna(df[order_amount].median()) # 7. 处理配送时长众数填充 delivery_mode df[delivery_time].mode()[0] df[delivery_time] df[delivery_time].fillna(delivery_mode) # 8. 最终报告 print(处理后各列缺失数) print(df.isnull().sum()) print(f最终数据形状: {df.shape})这个流程走完之后整个表就不再存在任何NaN可以直接进入下一步的探索性分析和建模。同时age_missing和order_amount_missing这两个标记列被保留它们是后续分析中可选的观察维度。6.4 三步验证方案判断处理效果填充完之后我会习惯性做三件事保证处理结果可信。第一件是对比填充前后的分布形态。均值、中位数、标准差、分位数都要看尤其注意填充后均值有没有被明显拉偏分位数分布有没有出现断崖式变化。# 填充前后对比 print(age填充前描述统计) print(df_age_subset[age].describe()) print(age填充后描述统计) print(df[age].describe())第二件是抽样检查。手动抽几个原表中有缺失的样本看看填充值是否合理。比如某行用户等级是“钻石”年龄缺失填充值为45岁跟该组中位数一致说明逻辑正确如果某行是“普通”等级却被填进45岁就要检查是不是分组错误。第三件是模型效果对比。如果你是在做机器学习项目可以做一次A/B对比一组用填充后的数据训练另一组用简单删除缺失行的数据训练比较验证集上的指标。这个对比不用特别规范只要在同一测试集上跑就行。通常你会发现合理填充带来的收益要大于直接删除尤其是缺失率超过10%的字段。7. 常见问题与避坑指南7.1 高频报错与解除方法先说几个我见过无数次的报错。inplaceTrue不生效这是很多老教程误导的结果。fillna(..., inplaceTrue)并不是所有版本、所有操作都可靠而且它会修改原对象容易在链式操作里埋雷。我的建议很简单不用inplace一律df df.fillna(...)重新赋值。代码可读性更好也不会出现“明明调用了但数据没变”的幻觉。fillna之后还是有缺失值这个坑最常见。原因通常是填充值本身是NaN比如df[col].mode()[0]在极端情况下可能取到NaN你填充时传的是一个长度不匹配的Seriespandas按索引对齐失败直接没填上缺失值其实是空字符串而不是NaNfillna只认NaN对空字符串无动于衷。排查办法是先df.isnull().sum()然后用df[df[col].isna()]把残留的缺失行全打出来看它们长什么样。绝大多数情况下能看到问题根源。ValueError: array must not contain infs or NaNs是机器学习建模时报的。这种情况往往不是你的DataFrame里肉眼可见的NaN而是计算中间过程产生了inf。比如做了除法分母为0结果变成inf。某些fillna只处理了NaN没处理inf。所以我会在做完填充后顺手把所有数值列的inf也替换掉df.replace([np.inf, -np.inf], np.nan, inplaceTrue) df df.fillna(df.median())7.2 什么时候千万别用fillna虽然这篇讲了这么多填充方法但我必须强调几个不推荐硬填的场景。高缺失率字段比如超过70%就别强行填充了。填出来的值本质上都是噪声不仅没有信息增量还会让你误以为这个字段质量不错。这种情况下要么直接删掉该字段要么把“缺失与否”转成二值特征让模型自己学。时间序列里的远期缺失比如连续几个小时甚至几天没数据fillna和ffill都很危险。你等于用上一时刻的状态代表整个缺口的业务状态如果这段时间里正好有大促或系统故障结果会完全失真。更稳妥的做法是用临近但不同日期的同时刻数据替换或者放弃这段时间段。关键业务指标字段比如用户余额、订单金额、库存数量这些一旦缺失填充带来的审计风险很高。如果做财务或合规分析每一笔缺失都需要报备不能静默填充。我在金融项目里遇到过这种场景最后是跟业务约定缺失值一律标记不填充分析时单独建一层“未知值”分组不能绕过风控审计。7.3 缺失率阈值参考速查表最后给一个我自己常用的经验表不是绝对标准但可以当参考缺失率建议处理方式说明低于1%直接删除或忽略对整体影响很小不必为此引入填充偏差1% - 5%简单填充或删除看样本量样本大就删样本小就用均值/中位数/众数5% - 20%分组填充或模型填充这是主力处理区间重点看缺失机制20% - 50%模型填充 缺失标记单独填充已经不够必须建标记列高于50%删除或转成二值特征填充等于制造谎言谨慎评估再用这个表的分界值不是数学定理我是基于大量实战总结出来的经验值。缺失率只是个起点真正拍板还是要结合业务场景和样本量。7.4 其他项目里总结出来的补充小技巧最后再补充几个不在标题里但做项目几乎必用的技巧。一是复盘时要记录清楚哪些列、哪些规则、填了多少。我会在代码开头写一段数据清洗日志记录处理日期、每个字段处理方式、填充前缺失数量、填充后效果。这份文档比代码本身更值钱因为三个月后你能快速回忆起当时的决策逻辑也能让团队其他人快速接手。二是注意填充后是否让数据“太好”。有时候你填充得太精细模型在训练集上的表现会虚高但上线后因为真实缺失模式跟训练时不一致效果直接崩。这个问题我之前提过但每次都要强调。解决方案就是不只用一种填充策略做最终模型而是把填充规则做成可配置的训练和上线尽量保持一致。三是如果你发现某个字段缺失率极高而且和另一个字段强相关可以考虑直接用另一个字段推导而不是单纯填充。比如“会员等级”缺失但“累计积分”存在且你知道积分超过1万必是黄金会员这种业务规则直接映射即可不需要统计填充。写在最后的一点体会处理缺失值这件事做数据分析的人几乎天天碰但我发现很多人从初级到中级进步最大的分水岭就是从“会填”变成“会选”。一开始我也是这样见到NaN就删删完发现样本量不够就换均值填填完发现不自然又换插值。直到后来在几个真实项目里做效果对比才慢慢摸清了不同方法的边界——再有经验的统计学家也猜不出客户真实的年龄但你能做的是选择那个最不容易引入系统性偏差的方案。如果你现在手头正好有个数据集要处理建议先花十分钟做两件事跑一下df.isnull().mean()再画一张msno.matrix图。看清楚缺失长什么样再回来翻这篇文章按字段逐个定策略。如果处理完之后你能清晰地写出一句话“这个字段缺失率多少、为什么缺、我为什么这么填、填完有什么影响”那你就比大多数只会在fillna后蒙头建模的同学专业一个档次了。
返回列表