Pandas DataFrame缺失值处理全攻略:从检测到填充的完整实践
2026/9/9 3:23:48 网站建设 项目流程

做数据分析,尤其是真正跑项目的朋友,一定有过这种体验:拿到的数据表干干净净、一行不缺一列不少的情况几乎不存在。绝大多数时候,第一步面对的就是各种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', periods=n, freq='min'), '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, size=150, replace=False) df.loc[missing_idx, 'age'] = np.nan missing_idx2 = np.random.choice(n, size=300, replace=False) df.loc[missing_idx2, 'order_amount'] = np.nan df.loc[np.random.choice(n, size=80, replace=False), 'delivery_time'] = np.nan df.loc[np.random.choice(n, size=50, replace=False), 'gender'] = None df.loc[np.random.choice(n, size=30, replace=False), 'coupon_discount'] = -1 # 伪装缺失 # 基础检测 print(df.isnull().sum()) print(df.isna().mean().round(4) * 100) # 缺失率百分比

isnull()isna()完全等价,一个是SQL风格命名,一个是统计风格命名,你习惯用哪个都行。输出缺失率百分比这个操作容易被忽略,但它其实特别重要——缺失率低于1%的字段和缺失率30%的字段,处理策略完全不是一个量级。

但还有个细节很多人会漏:NaN只是pandas里最标准的缺失标识,实际业务数据里的缺失往往穿着各种马甲,比如空字符串''、特殊占位符-19999'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}, inplace=True) print(df.isnull().sum())

这一步能在项目早期拦住大量脏数据。我在真实项目里遇到过客户表里“年龄”字段有200多个-1,后来才知道是前端表单默认值,业务上就是不详。这种伪装缺失不改写成NaN,后面做任何统计都会把-1当成真实值参与计算,均值直接被拉低5岁。

2.2 缺失值可视化:一眼看出缺失模式

数量级搞清楚之后,我建议再做一步可视化,尤其当数据列数比较多的时候。Python生态里有个专门干这个的库叫missingno,装一下就能用:

pip install missingno matplotlib
import 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也经常缺失,那这两个字段的缺失可能同源,处理时可以合并考虑。

这一步往往能帮你发现手工数数发现不了的结构性问题。我给一个真实例子:某次做用户画像,jobincome两个字段缺失率分别是35%和28%,msno.heatmap显示相关系数0.8以上,说明失业或自由职业人群大概率同时不填这两项。这时候如果分别填充,等于把“从事低收入或不稳定职业”的群体强行估计成平均收入,分析结论肯定偏。

3. 删除策略:不是所有缺失都值得你费力抢救

3.1 dropna的完整参数拆解

删除是最简单粗暴的方案,但要删除得有章法。dropna有几个参数,每一个都有讲究:

# 默认参数:删除任何含有NaN的行 df_clean = df.dropna() # 只删除指定列全为NaN的行(本例中无效,因为没有整行全空的) df_clean = df.dropna(how='all') # 只对指定的列检查缺失,删掉这些列上有NaN的行 df_drop_age = df.dropna(subset=['age']) # 非NaN数量至少为4,才保留该行 df_clean = df.dropna(thresh=4) # 横向删除:按列删,只要这一列有缺失就删整列 df_clean = df.dropna(axis=1)

how='all'只在整行全部字段都是NaN时才删,适合处理采集失败产生的完全空记录,对普通情形用处不大。subset是最常用的参数,意思是只要指定列有缺失,就删掉那一行。我通常建议用subset而不是全表dropna,因为不同列的缺失严重程度不一样,一刀切全删,数据量可能直接崩掉。thresh参数是保留“非缺失字段数不少于某个阈值”的行,适合那种一行里只要关键字段够多就留着的场景,常用于处理问卷数据。

还有一个常见误区是inplace=True。我的建议是:永远不要用。这东西在pandas老版本里是内存优化手段,但现在链式写法更清晰、更不容易出bug。你直接df = df.dropna(...)重新赋值就行,inplace=True在pandas未来的版本里都打算废弃了。

3.2 什么情况下应该考虑删除而不是填充

删除会降低样本量,所以必须有个明确标准。我给一个自己项目的经验法则,不一定是最优解,但至少是个不犯错的底线:

第一,缺失率低于5%且样本总量足够大的时候,dropna(subset=['某关键列'])是个舒服的选择。比如你有100万行,某字段缺3万行,删掉对这列的分析完全无伤大雅,还省了填充可能引入的偏差。第二,缺失值集中在某个你根本不会用到的字段,那直接删列或删行都行,别浪费精力。第三,缺失机制是MNAR且缺失比例高,如果领域经验告诉你填了也是瞎填,删除并明确记录“该字段有效样本量”反而比胡乱填充更诚实。

但凡是下面这些情况,删除就要非常谨慎了:样本量本来就小(比如只有两三百条),删完统计功效不足;缺失不是随机出现,删行会引入选择性偏差;该字段是模型的核心特征,删光后模型直接哑火。

3.3 删除操作的注意事项

删行之后要立刻检查结果,尤其是索引。pandas删行后索引会保留原来的编号,出现很多“空洞”,这在后续mergejoingroupby的时候会产生一些莫名其妙的边界行为。我的习惯是操作完就跑一句df.reset_index(drop=True, inplace=True),把索引重新整理成连续的,这样最省心,后面调试也不会犯糊涂。

df_drop = df.dropna(subset=['age']).reset_index(drop=True) 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(f"order_amount均值: {df['order_amount'].mean():.2f}") print(f"order_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直接groupbyfillna,因为索引对不齐,大概率会报错。这个错我当年也踩过,所以重点提一下。

还有一种更实用的操作:用时间维度做分组。很多业务数据有月份、星期的周期性,比如电商客单价周末和工作日差异很大,缺失的订单金额如果按整周平均填充,必然失真。按星期几分组填充,效果会好很多。

关于分组填充,还有一个重要的前置动作:groupby里如果分组依据的字段本身也有缺失,分组时会自动忽略这些样本,它们没法被填充,这时你要先处理分组字段的缺失,再处理目标字段的缺失。

4.3 前后向填充与时间序列场景

method='ffill'method='bfill'是时间序列和面板数据里的保留节目。前者用上一条非缺失值填充,后者用下一条非缺失值填充。典型场景是传感器采集:设备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无能为力,会保留NaN;bfill同理处理序列结尾的缺失。实战中我会组合用:先ffill,再对剩余的NaN用bfill,或者反过来,尽可能把边界缺失也覆盖掉。

另外还有一个容易被忽略的需求:limit参数。有些场景下你不想让缺失值被一路填充到底。比如物联网设备断电了3个小时,中间的缺失值你用上一小时的数据推,能接受,但如果断了一整天,你还一路填充,那就是在制造假数据。这种场景可以限制最大填充长度:

# 最多填充2个缺失位置,再多就保持NaN ts_filled_limited = ts.ffill(limit=2) 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(method='spline', order=3) print(s_spline)

线性插值的逻辑很简单:两个已知点之间连一条直线,缺失值按位置比例分配到直线上。上面例子里12和16之间缺一个数,取中间值14。样条插值则是用一个平滑曲线去拟合整段数据,然后取缺失位置对应的曲线值,结果更自然,但也要小心过拟合的问题,尤其数据本身噪声很大的时候。

有一个关键参数:limit_direction,控制插值方向。默认'forward'是只往缺失位置的后方插值,'both'表示前后都插,'backward'则是往前。如果你希望序列首尾的缺失也处理掉,需要显式设置limit_direction='both'

# 首尾缺失也处理 s2 = pd.Series([np.nan, 12, np.nan, 16, 18, np.nan, np.nan, 24, np.nan]) print(s2.interpolate(limit_direction='both'))

5.2 时间序列插值:method='time'

如果索引是时间类型,线性插值默认按位置等距处理,但真实时间序列里两个观测点之间的时间间隔往往不一样。这时候用method='time',pandas会按实际时间间隔做线性插值,结果更符合时间语义。

# 构造不规则时间索引 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], index=ts_index) # 按时间间隔插值 vs 按位置插值 print("按时间间隔:", ts_data.interpolate(method='time').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.SimpleImputerKNNImputer

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_neighbors=5) features_filled = imputer_knn.fit_transform(features_scaled) # 逆标准化还原 df_knn_filled = pd.DataFrame(features_filled, columns=features.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}, inplace=True) # 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(drop=True) # 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(bins=50) plt.title('order_amount分布(填充前)') plt.show() print(f"order_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_missingorder_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 高频报错与解除方法

先说几个我见过无数次的报错。

inplace=True不生效,这是很多老教程误导的结果。fillna(..., inplace=True)并不是所有版本、所有操作都可靠,而且它会修改原对象,容易在链式操作里埋雷。我的建议很简单:不用inplace,一律df = df.fillna(...)重新赋值。代码可读性更好,也不会出现“明明调用了但数据没变”的幻觉。

fillna之后还是有缺失值,这个坑最常见。原因通常是:

  • 填充值本身是NaN,比如df['col'].mode()[0]在极端情况下可能取到NaN;
  • 你填充时传的是一个长度不匹配的Series,pandas按索引对齐失败,直接没填上;
  • 缺失值其实是空字符串''而不是NaN,fillna只认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, inplace=True) df = df.fillna(df.median())

7.2 什么时候千万别用fillna

虽然这篇讲了这么多填充方法,但我必须强调几个不推荐硬填的场景。

高缺失率字段(比如超过70%)就别强行填充了。填出来的值本质上都是噪声,不仅没有信息增量,还会让你误以为这个字段质量不错。这种情况下,要么直接删掉该字段,要么把“缺失与否”转成二值特征,让模型自己学。

时间序列里的远期缺失,比如连续几个小时甚至几天没数据,fillnaffill都很危险。你等于用上一时刻的状态代表整个缺口的业务状态,如果这段时间里正好有大促或系统故障,结果会完全失真。更稳妥的做法是用临近但不同日期的同时刻数据替换,或者放弃这段时间段。

关键业务指标字段,比如用户余额、订单金额、库存数量,这些一旦缺失,填充带来的审计风险很高。如果做财务或合规分析,每一笔缺失都需要报备,不能静默填充。我在金融项目里遇到过这种场景,最后是跟业务约定:缺失值一律标记,不填充,分析时单独建一层“未知值”分组,不能绕过风控审计。

7.3 缺失率阈值参考速查表

最后给一个我自己常用的经验表,不是绝对标准,但可以当参考:

缺失率建议处理方式说明
低于1%直接删除或忽略对整体影响很小,不必为此引入填充偏差
1% - 5%简单填充或删除看样本量,样本大就删,样本小就用均值/中位数/众数
5% - 20%分组填充或模型填充这是主力处理区间,重点看缺失机制
20% - 50%模型填充 + 缺失标记单独填充已经不够,必须建标记列
高于50%删除或转成二值特征填充等于制造谎言,谨慎评估再用

这个表的分界值不是数学定理,我是基于大量实战总结出来的经验值。缺失率只是个起点,真正拍板还是要结合业务场景和样本量。

7.4 其他项目里总结出来的补充小技巧

最后再补充几个不在标题里但做项目几乎必用的技巧。

一是复盘时要记录清楚:哪些列、哪些规则、填了多少。我会在代码开头写一段数据清洗日志,记录处理日期、每个字段处理方式、填充前缺失数量、填充后效果。这份文档比代码本身更值钱,因为三个月后你能快速回忆起当时的决策逻辑,也能让团队其他人快速接手。

二是注意填充后是否让数据“太好”。有时候你填充得太精细,模型在训练集上的表现会虚高,但上线后因为真实缺失模式跟训练时不一致,效果直接崩。这个问题我之前提过,但每次都要强调。解决方案就是不只用一种填充策略做最终模型,而是把填充规则做成可配置的,训练和上线尽量保持一致。

三是如果你发现某个字段缺失率极高,而且和另一个字段强相关,可以考虑直接用另一个字段推导,而不是单纯填充。比如“会员等级”缺失,但“累计积分”存在,且你知道积分超过1万必是黄金会员,这种业务规则直接映射即可,不需要统计填充。

写在最后的一点体会

处理缺失值这件事,做数据分析的人几乎天天碰,但我发现很多人从初级到中级,进步最大的分水岭,就是从“会填”变成“会选”。一开始我也是这样:见到NaN就删,删完发现样本量不够就换均值填,填完发现不自然又换插值。直到后来在几个真实项目里做效果对比,才慢慢摸清了不同方法的边界——再有经验的统计学家也猜不出客户真实的年龄,但你能做的是选择那个最不容易引入系统性偏差的方案。

如果你现在手头正好有个数据集要处理,建议先花十分钟做两件事:跑一下df.isnull().mean(),再画一张msno.matrix图。看清楚缺失长什么样,再回来翻这篇文章,按字段逐个定策略。如果处理完之后,你能清晰地写出一句话:“这个字段缺失率多少、为什么缺、我为什么这么填、填完有什么影响”,那你就比大多数只会在fillna后蒙头建模的同学专业一个档次了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询