做数据分析这些年,我处理过不少乱七八糟的原始数据,要说哪个步骤最不起眼却最容易让整个分析翻车,Pandas缺失值处理绝对排得上号。表面上它就是DataFrame里空着的那几个格子,实际上一不留意就能让你后面的统计、建模、落盘全是错的。这篇博文我不打算念官方文档,而是把项目里踩过的坑、常用的套路、以及为什么这样选型的逻辑都摊开讲,适合刚入门想系统掌握缺失值处理的读者,也适合已经写了很多pandas但总在边缘场景里犯迷糊的老手。
先说明一下,本文所有代码基于Pandas 1.5.x以上版本,Python 3.9或3.10均可。要是你还在纠结Python 3.10和哪个Pandas版本适配,直接装pandas>=1.5.0就没有兼容问题,老版本在numpy 1.24之后容易出一些莫名其妙的警告,没必要给自己添堵。
1. 缺数据不丢人:先搞清楚Pandas里的缺失值到底是什么
很多人一上来就只背dropna()和fillna()的用法,结果遇到数据里出现NaN、None、NaT、甚至空字符串时傻了眼。它们看起来都是“空”,但在Pandas内部根本不是一回事,处理方式也不一样。
1.1 NaN、None与NaT的底层区别
先抛结论:NaN是浮点数缺失值,None是Python对象缺失值,NaT是时间类型缺失值,而空字符串""在Pandas眼里不是缺失值,它是一个真实存在的字符串对象。这四者的“缺失程度”是完全不同的。
NaN全称是Not a Number,属于float类型。Pandas的浮点列缺省就是NaN,所以只要你对整数列做缺失值标记,它就会被强制转成float64,这个特性让很多人第一次吃瘪——明明我读进来的是“123”这种整数,怎么一处理就变成了“123.0”。None则是Python原生的空对象,如果一列是object类型,里面混着None是很正常的。NaT就是时间序列里的缺失值,专门用来标记时间戳落空,你用pd.to_datetime解析时间字段时,解析不了的值往往就会变成NaT。
python复制import pandas as pd
import numpy as np
df = pd.DataFrame({
"数值列": [1.0, np.nan, 3.0],
"对象列": ["a", None, "c"],
"时间列": pd.to_datetime(["2024-01-01", None, "2024-01-03"])
})
print(df.dtypes)
输出结果很直观:数值列是float64,对象列是object,时间列是datetime64[ns]。你可以看到None在时间列里会被Pandas自动转换成NaT,而不是保留为None。这就是Pandas在底层帮我们做了一次“缺失值归一化”。
但这里有一个关键坑:Pandas只会在特定操作里把None自动转成NaN,比如构造DataFrame、read_csv解析时。如果你在object列里手动写入None,然后去做数值运算,它可能不会报错,但结果往往不符合预期,因为None和NaN在比较和运算时表现不同。
python复制# 对object列的None做加法,会直接报错
try:
print(df["对象列"] + "suffix")
except TypeError as e:
print("TypeError:", e)
而对数值列做加法时,NaN会传播,比如df["数值列"].sum()默认会忽略NaN,但df["数值列"] + 1会把NaN保留下来。很多人就是栽在这个“默认忽略”和“参与运算时传播”的区别上。
1.2 为什么“看得到却查不到”:isnull与==的比较坑
我见过最多的问题就是有人用df[df["列名"] == np.nan]去筛缺失值,结果一行都筛不出来。原因很简单:NaN != NaN。这不是Pandas的问题,而是IEEE浮点数规范本身就规定NaN不等于任何值,包括它自己。你要判断一个单元格是不是缺失,必须用pd.isna()或isnull()。
python复制# 错误示范,永远筛不出来
print(df[df["数值列"] == np.nan])
# 正确写法
print(df[df["数值列"].isna()])
isnull()和isna()是同一个东西,isna是别名。我习惯用isna(),因为英文里“na”更短,和notna()对应起来也好记。但要注意,isna()只能识别NaN、None、NaT,识别不了空字符串、空格、"NA"这种字符串文本。实际业务数据里经常用"NULL"、"N/A"、"-"这种占位符表示缺失,读进来之后不会自动变成NaN,需要你手动替换。这部分我会在后面的章节专门讲,因为这是实战中最容易翻车的地方。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先摸清家底:缺失值检测、统计与可视化三板斧
拿到数据不要急着填,先把缺失情况摸清楚。这一步做扎实了,后面的处理策略才有依据。我一般会做三件事:看数据概貌、算缺失比例、画缺失热图。
2.1 用info和isnull快速掌握数据全貌
第一次接触一份数据,df.info()是最快的体检入口。它会把每一列的非空数量、数据类型、内存占用一次性列出来,缺失值的“重灾区”一眼就能看到。
python复制df.info()
我举个例子,某份用户行为日志有100万行,info()显示user_id列非空只有80万,说明有20万行缺用户,这时候你就要判断这些缺失是漏斗流失的正常表现,还是采集端的问题。df.isnull().sum()则能给出具体每一列缺失数量的精确值,配合sort_values()可以快速定位缺失最多的字段。
python复制missing_count = df.isnull().sum()
missing_count = missing_count[missing_count > 0].sort_values(ascending=False)
print(missing_count)
2.2 缺失值比例与相关性:比计数更有用的统计口径
只看绝对数不够,缺失比例才决定你能不能删除。100万行里缺失5万,比例5%,可能可以接受;10行里缺失5行,比例50%,删了就伤筋动骨。所以我会把比例放在一起算:
python复制missing_ratio = df.isnull().mean().sort_values(ascending=False)
print(missing_ratio)
isnull().mean()是很多人不知道的小技巧,因为布尔值的均值就是缺失比例,一列搞定,不用再除以len(df)。
如果有兴趣,还可以看看列与列之间的缺失是否有关联。比如一个业务表里成交金额和成交时间同时缺失,可能说明这些记录本身就是未成交的行为,缺失模式背后藏着业务含义。想验证相关性,可以构造缺失标记矩阵,然后做列之间的相关分析:
python复制missing_matrix = df.isnull()
corr = missing_matrix.corr()
不过这里要提醒一句:当某一列缺失比例超过90%时,相关分析很容易出现误导性结果,因为样本量太少。这种情况我通常会先想清楚这列还要不要,再决定是否纳入关联分析。
2.3 用热图让缺失模式一目了然
热图是快速发现缺失规律的一把好手。最常用的是missingno这个库,一两行代码就能画出缺失热图和缺失条形图。
python复制# 安装:pip install missingno
import missingno as msno
msno.matrix(df)
生成的矩阵图里,横向是列,纵向是行,白色的条纹就是缺失位置。如果缺失位置呈现明显的带状或块状分布,说明缺失不是随机的,而是和某些时间段、某些批次有关。比如日志数据里某几天的数据全是空的,热图上就是一道明显的白色横带,这时你心里就有数了:缺失可能是采集服务宕机导致的,而不是单个字段随机丢失。
如果不想额外装库,用seaborn画热图也能顶一下,但missingno专门为缺失设计,msno.heatmap(df)还能直接画出列间缺失相关性热图,比手工算corr()要快得多。我自己的习惯是:先msno.matrix看整体,再用msno.heatmap看列间关系,基本就能确定处理策略了。
3. 该删就删:dropna的正确打开方式与适用边界
缺失值处理最粗暴的办法是删除。但“删除”不是无脑dropna(),要看你删除的是行还是列,是按“只要有一个缺失就删”还是“整行全缺才删”。这些参数逻辑搞错了,数据质量会大打折扣。
3.1 dropna参数详解:axis、how、thresh怎么配
DataFrame.dropna()最核心的参数有三个:axis、how、thresh。
axis=0表示删除行,axis=1表示删除列,默认是axis=0。how有两个值:'any'表示该行/列只要有缺失就删除,'all'表示全部缺失才删除。thresh表示该行/列至少要有多少个非缺失值才保留,剩下的统统删除。
举个例子,原始数据长这样:
python复制df = pd.DataFrame({
"A": [1, np.nan, 3, 4],
"B": [np.nan, np.nan, 3, 4],
"C": [1, 2, np.nan, np.nan],
"D": [1, 2, 3, np.nan]
})
如果执行df.dropna(),默认axis=0, how='any',只要一行里有任何一个缺失就删,结果只剩第1行。这在很多场景下太激进。如果改成df.dropna(how='all'),也删不了多少,因为数据很少出现整行全空。
业界更常用的其实是thresh。比如你有一个包含100个特征的表,你想保留那些在特征层面信息量足够的样本,可以设置df.dropna(thresh=80),意思是每一行至少要有80个非空特征才保留。这在特征工程里非常实用,能有效剔除那些大量特征缺失的“僵尸样本”。
python复制# 每行至少要有2个非缺失值,否则删除
df_clean = df.dropna(thresh=2)
print(df_clean)
3.2 什么时候应该大胆删除,什么时候删了会出大问题
删除操作看起来简单,真正难的是判断什么时候能删。根据我的经验,从两个角度考虑:
一是看缺失比例和被删后的数据量。如果数据有几百万行,缺失只有几百行,那直接删除完全没问题,不用纠结。如果缺失比例超过30%,删除会明显改变样本分布,这时候就要掂量掂量。比如做用户画像分析,删除20%的用户,最后得出的画像可能只代表“数据完整的活跃用户”,而不是真正的全体用户。
二是看缺失机制。如果数据是随机缺失(MCAR),删除往往是无偏的;如果缺失和某些潜在因素有关(MNAR),比如低收入用户更不愿意填收入字段,那删除就会引入系统性偏差。这个在统计学术语里讲得很深,实际工作中我们不可能做严谨的假设检验,但至少要心里有数:关键标签列缺失时,尽量不要删行,改用填充或者单独聚合;非关键特征列缺失时,如果该列本身信息量低,可以直接删列。
python复制# 删除缺失比例超过50%的列
df_clean = df.dropna(axis=1, thresh=int(len(df) * 0.5))
这个代码含义是:每一列至少有50%的非空值才保留。实操中我会把这个阈值放到0.7或0.6,看业务容忍度。还是那句老话,列缺失比例太高,填充几乎等于编数据,删掉比强行填更诚信。
4. 该填就填:fillna的选择逻辑与方法论
有些场景不能删,删了信息损失太多,这时候就要填。但填充策略不是随便拿个均值填上去,你得清楚填进的每一个值其实都在向数据里注入“人为假设”,填错了比不填更有害。
4.1 固定值、统计值填充:只适合特定场景
最简单的是用固定值填,比如性别未知填"未知",收入缺失填0,这种用df["列名"] = df["列名"].fillna("未知")就能搞定。但固定值填充有一个大坑:它会把缺失归一化到同一个值,这个值一旦参与计算,会拉低方差、扭曲分布。比如给某商品价格缺失填0,后面一算平均价格,直接被0拉下去一大截。
用均值、中位数、众数填充是默认操作,但也要分情况。均值对异常值敏感,如果数据分布右偏,均值比中位数高很多,填均值会让中心趋势偏移。我一般建议:有偏数据用中位数,分类字段用众数,只有接近正态分布的数据才用均值。
python复制df["年龄"].fillna(df["年龄"].median(), inplace=True)
还要注意,填充前最好分组考虑。不是所有群体的均值都一样,全局均值填进去可能造成“辛普森悖论”,后续分析会得出错误结论。这个在下一章专门讲。
4.2 时间序列怎么填:ffill、bfill与限制填充范围
时间序列数据缺失时,前向填充(ffill)和后向填充(bfill)是常见手法。前向填充的意思是用上一个观测值填充当前缺失,后向填充则相反。比如传感器每5秒采集一次,某几秒数据没采到,用最近的一次有效值顶上,业务上说得通。
python复制df["指标"].ffill(inplace=True)
# 或
df["指标"].bfill(inplace=True)
但这里有个非常容易踩的坑:如果数据开头就缺失,ffill填不上,结果还是NaN;数据结尾缺失,bfill也填不上。所以很多时候要两个结合起来:
python复制df["指标"].ffill(inplace=True)
df["指标"].bfill(inplace=True)
另外,ffill和bfill都支持limit参数,用来限制连续填充的窗口大小。比如股票数据分析里,某只股票停牌一小时,你不能让前一天的收盘价一直往后填一天,那不是真实交易数据。df["价格"].ffill(limit=3)只允许连续填充3个缺失点,超过就保持缺失,这样能避免长时间“惯性填充”带来的失真。
4.3 interpolate插值:从线性到多项式,别乱用
除了前向/后向填充,interpolate()是应对时间序列和数值趋势更“聪明”的办法。它会根据已有数据点之间建立插值曲线,把缺失点估算出来。默认是线性插值,也就是把缺失位置左右两个值连一条直线,取中间的值。
python复制df["温度"].interpolate(inplace=True)
线性插值适合数值变化比较平滑的场景,比如温度、水位、连续型传感器数据。如果数据波动剧烈,线性插值反而会把尖峰抹平。interpolate(method='polynomial', order=2)可以用多项式插值拟合曲线,但阶数太高容易过拟合,产生非常离谱的外推值,除了少数研究型场景,我不推荐业务数据分析用高阶多项式。
还要注意,interpolate在默认情况下是按行索引顺序操作的,如果索引是无序的或者存在重复,要先sort_index()。如果是按时间序列插值,最好用method='time',这样会考虑索引的时间间隔,而不是机械地按位置等距插值。
python复制df = df.sort_index()
df["温度"].interpolate(method="time", inplace=True)
5. 分组填充、inplace和链式赋值的坑:我的实战笔记
到了这一章,我会把平时写代码时经常踩的几个坑单独拎出来说。这些都是文档里不会细讲,但实际跑起来会让你反复掉头发的细节。
5.1 按组填充均值/中位数,比全局填充更贴近业务
回到前面提到的分组意识。假设你有多个城市的销售数据,城市A的平均客单价是500元,城市B的是80元,如果全局用所有城市混合的均值300元去填缺失值,你会发现城市B的缺失值全被高估了,后面按城市做促销分析时结果完全失真。
正确做法是分组统计后填充:
python复制df["客单价"] = df.groupby("城市")["客单价"].transform(lambda x: x.fillna(x.median()))
这里用transform而不是apply,因为transform会返回和原DataFrame相同索引的结果,可以直接赋值回原列。要是用apply,返回结果可能是一堆Series,赋值时索引对不上就会出错。
还有一个更简洁的写法,先计算每组的填充值,再map回去:
python复制fill_map = df.groupby("城市")["客单价"].median()
df["客单价"] = df["客单价"].fillna(df["城市"].map(fill_map))
两种写法都行,但我推荐第一种transform,因为它能用一行代码处理更复杂的逻辑,比如需要条件判断时扩展起来也方便。
5.2 inplace=True为什么是隐藏的坑
Pandas早期版本里,很多人喜欢写df.fillna(0, inplace=True),觉得省事。但后来官方社区开始不推荐inplace参数,因为它有一堆隐藏问题。
最核心的问题有两点:第一,inplace=True并不是最省内存的操作,Pandas内部仍然会创建临时对象;第二,当DataFrame是某个更大对象的切片(如df_sub = df[df["城市"]=="A"])时,对切片执行inplace操作可能会触发SettingWithCopyWarning,导致修改不生效却没有任何错误提示,或者更糟糕,它会带意外地修改了原DataFrame,让整个数据在不知不觉中被污染。
我现在的习惯是彻底不用inplace,全部写成赋值式:
python复制df = df.fillna(0)
# 或
df["列名"] = df["列名"].fillna(0)
这样写的好处是每一步都有明确的返回结果,能清楚看到数据被重新绑定到了哪个变量,调试起来心不慌。即使性能上略有一点点开销,在绝大多数业务数据规模下完全不是瓶颈,更重要的是可读性和可维护性。
5.3 链式赋值的崩溃现场与正确姿势
链式赋值(chained assignment)是Pandas新手最容易遇到的魔幻问题。比如你写:
python复制df[df["年龄"].isnull()]["收入"] = 0
你以为把收入列的缺失值填成0了,实际上这个操作很可能什么都没干,或者只修改了一个副本,Pandas在后台给你抛了个警告:SettingWithCopyWarning。原因很直接,df[df["年龄"].isnull()]这一步返回的是一个新对象,你再对新对象赋值,原DataFrame根本不知道。
正确姿势是用loc定位到需要修改的行和列:
python复制df.loc[df["年龄"].isnull(), "收入"] = 0
这里loc同时接收行条件和列名,直接操作原始DataFrame的视图,没有中间副本,也就没有赋值丢失的风险。我建议所有需要按条件填充或修改数据的操作,都优先用loc。
还有一个相关场景:按条件填充时如果条件来自另一列,很多人会写多重apply,实际用loc会清晰得多。例如“年龄缺失且城市为北京”的样本,收入填为某值:
python复制mask = df["年龄"].isnull() & (df["城市"] == "北京")
df.loc[mask, "收入"] = 5000
6. 从DataFrame到Parquet/Feather:缺失值在实际落盘中的表现
很多人做数据处理时只关注内存里的DataFrame,一保存成文件、再读回来,发现“变味了”。其实缺失值在序列化到不同文件格式时,有各自的行为表现。热词里提到的Parquet和Feather就是现在很火的两列式存储格式,它们天然对缺失值有更好的支持,但也暗藏几个坑。
6.1 为什么换格式后字段类型会变
Parquet和Feather都会把Pandas的索引、列数据类型、缺失值标记一起写入文件。但和CSV不同,CSV里缺失值写成了空字符串或者“NA”,读回来之后要重新识别;而Parquet会在schema里明确记录字段的可空性,缺失值仍然是NaN,类型也会尽量保留。
不过一个很常见的现象是:整数列在缺失值写入Parquet后,再读回来变成了float64。原因很简单,因为Parquet规范本身没有把“整数列中的缺失值”当作一个一等公民,缺失标记仍需要用一个值来表达,Pandas选择用NaN,而NaN是浮点类型,所以整数列只能被提升成浮点列。
如果非要保留整数类型,一个折中方案是使用Int64(注意是Pandas的可空整数类型,首字母大写):
python复制df["整数列"] = df["整数列"].astype("Int64")
df.to_parquet("data.parquet")
这样写出来的Parquet文件可以保留整数和缺失值,而不是降级成浮点。这在业务上非常实用,比如员工编号、订单号这种本不该是小数却带了个.0的字段,用Int64就能保持整洁。
6.2 常见“伪缺失值”处理与导入后验证
Parquet和Feather在缺失值处理上已经做得比CSV好很多,但真实业务数据里最常见的缺失值其实是“伪缺失”——就是那个空字符串、"null"、"N/A"、"-"文本。它们存在的原因是上游系统导出时没做清洗,到了你的手里才变成麻烦。
我在读入数据后一定会先做一遍“伪缺失识别”:
python复制df = pd.read_csv("raw_data.csv", na_values=["", "null", "N/A", "/", "-", "NULL"])
read_csv有na_values参数,可以直接把常见的占位符映射成NaN,省得后面一步步替换。如果已经读进来了,也可以批量替换:
python复制df = df.replace({"": np.nan, "null": np.nan, "N/A": np.nan})
但这里要注意,替换的列如果是数值列,可能受到类型影响导致报错。稳妥的做法是先把这些列转成object,替换完再按需转回数值类型。
最后,落盘之后一定要重新读回来验证一遍缺失值情况,特别是在用Parquet/Feather这类二进制格式时。我的习惯是无论用to_parquet还是to_feather,保存后都会立刻用pd.read_parquet或pd.read_feather读回来,执行一次df_loaded.isnull().sum(),和保存前核对。别嫌这一步麻烦,我曾经因为读回来的Parquet里出现了大量None对象,导致后面特征工程里的isna()统计全错,就是因为没有验证schema变化。
至于安装上,直接pip install pandas pyarrow就能把Parquet/Feather的读写依赖一起装好。我遇到过不少人在跑to_parquet时报错缺少引擎,其实就是没装pyarrow,装完就好了。
我个人在实际操作中的体会是,缺失值处理没有一劳永逸的银弹,每一个填进去的值、删掉的一行数据,都是对现实世界的一次简化。想清楚业务含义再动手,比掌握再多的API都重要。最后再分享一个小习惯:每次处理完缺失值,我都会打印一张前后缺失数量对比表,随手记在项目备注里,这样后面回溯报告时不用再猜当时到底做了什么。这招对我来说很实用,也推荐你试试。
