1. 为什么缺失值处理是数据分析的第一个分水岭
不管是处理爬虫抓来的原始数据、业务导出的Excel报表,还是接数仓里其他人跑出来的表,几乎没有人能避开那一行行刺眼的NaN、None或者空白。我见过不少初学者拿到数据后第一件事就是data.dropna()一把梭,删完了直接开算,结果后面特征工程做到一半发现行数对不上、逻辑乱套、结果怎么都不合理。也有朋友在群里问“填充空值到底用0还是用均值”,底下的回答五花八门,但很少有人能把背后的逻辑讲清楚。
先说个结论:空值处理不是简单的“删”或“填”二选一,它本质上是你在替数据做一次价值判断。删除意味着“这个样本失真严重,不值得保留”,填充意味着“这个字段的信息还有修复价值,我要用某种策略把它补上”。这个判断本身没有绝对的对错,但它会直接影响你后面所有分析结果的可信度。
Python里处理空值最核心的两个函数就是dropna()和fillna(),都出自pandas库。换句话说,你得先有pandas环境,然后对DataFrame或Series操作。本文的目标就是通过大量真实示例,把这两个函数的每个参数、每种常见玩法、每类容易踩的坑都讲透,让初学者能照着写,让用过的人能查漏补缺。
为了照顾零基础的读者,我先快速说两个基础点。第一,pandas里常见的空值表示法有numpy.nan(即NaN)、None,还有新版本里的pd.NA。它们在显示上可能长得很像,但类型上各有区别,判断空值时统一用pd.isna()或isnull()最保险。第二,判断一个DataFrame里有没有空值、有多少空值,常用的三板斧是df.isnull().sum()、df.info()和df.isnull().mean(),分别用来统计空值总数、看每列非空计数、看每列空值占比。这三步做完,你才真正知道手里的数据缺到了什么程度,也才能决定后面用dropna()还是fillna()。
2. 到底该删还是该填:先看数据状况再做决定
2.1 缺失比例是决策的第一指标
我个人的习惯是,拿到数据先算两个比例:单列缺失比例和整行缺失比例。单列缺失比例很好理解,就是某一列有多少比例是空的;整行缺失比例则要结合业务去看,比如一个用户的某条行为记录里,如果三四个关键字段同时为空,那这一行大概率是采集异常,删掉反而干净。
判断删除还是填充,我一般参考这几种情况:
- 该列空值占比超过70%:我几乎不会用填充去补,因为大部分填充策略(均值、中位数、众数)在这种极端稀疏的数据上没有意义。除非这列是待预测标签,否则直接删列。
- 空值占比在30%到70%之间:这个区间比较尴尬,删除会损失大量样本,盲目填充又可能引入噪声。我会看字段和业务目标的关系,如果是核心特征就想办法用模型或规则补,如果是边缘字段倾向于删除。
- 空值占比低于10%:大部分场景下可以直接填充,用均值/中位数/众数/前后值都行,对整体分布影响很小。
2.2 数据缺失机制对选择的影响
这里有个非常关键但经常被忽略的概念,叫缺失机制。统计上大致分三类:完全随机缺失(MCAR)、随机缺失(MAR)、非随机缺失(MNAR)。听起来像理论课,但我用大白话翻译一下你就懂了。
完全随机缺失的意思是说,某一行缺不缺失跟任何东西都没关系,纯属偶然,比如用户填问卷时手滑漏了一道题。这种情况下删掉也不损失什么信息,填充任何一种常见策略也不会引入系统性偏差。
随机缺失的意思是,某列缺失的概率跟数据里其他已观测到的字段有关,比如高收入人群更不愿意透露收入水平,而“职业”这一列往往是有记录的。这种情况删行会有偏差,填充时则可以考虑用分组统计量——比如按职业分组后的收入中位数去补,效果远好于全局中位数。
非随机缺失则意味着缺失本身就和数值大小有关,比如极小或极大的数值更容易缺失。这种最麻烦,普通的填充和删除都可能带偏结论,通常需要引入业务判断或更高级的方法。
别急着背术语,你只需要记住:被迫删除之前,先想想这行数据为什么缺失,如果缺失和你关心的业务结论可能有关系,删除会放大偏差。这比背一堆公式更有用。
3. dropna()函数:参数逐个拆解,示例说话
3.1 dropna()的默认行为与axis参数
dropna()最基础的用法是DataFrame.dropna(),什么都不传。默认情况下,pandas会删掉所有包含至少一个空值的行,方向是沿着axis=0(即行方向)。
import pandas as pd import numpy as np df = pd.DataFrame({ "姓名": ["张三", "李四", "王五", "赵六"], "年龄": [25, np.nan, 30, np.nan], "城市": ["北京", "上海", np.nan, "广州"] }) print(df) print("---- 默认 dropna() ----") print(df.dropna())运行结果:
姓名 年龄 城市 0 张三 25.0 北京 1 李四 NaN 上海 2 王五 30.0 NaN 3 赵六 NaN 广州 ---- 默认 dropna() ---- 姓名 年龄 城市 0 张三 25.0 北京因为李四、王五、赵六三行各自都有至少一个空值,默认操作把它们全删了,只剩张三一个人。这个例子很极端,但能清楚说明默认行为。实际用的时候,这种“只要有一列缺就整行删”的冷酷方式,往往会把样本量砍掉一截,所以要谨慎。
axis参数控制删除方向:axis=0表示删除行,axis=1表示删除列。很多人容易记混,我的记忆方法很粗暴:axis=0是沿着行的方向往下走,所以删的是行;axis=1是沿着列的方向往右走,所以删的是列。
print("---- axis=1,删除含空值的列 ----") print(df.dropna(axis=1))运行结果:
姓名 0 张三 1 李四 2 王五 3 赵六这次只剩“姓名”列了,因为“年龄”和“城市”都有空值。老实说,按列删除在真实项目中比想象中常用,尤其是批量导入的多个特征列里,若有几列几乎全空,删掉比留着做填充更干净。
3.2 how="any"还是how="all":决定删除的严格程度
how参数控制这个删除动作到底有多严格,可取值是"any"和"all",默认"any"。"any"意思是一行/列里只要有任意一个空值就删,"all"意思是一行/列里所有的值都是空值才删。
df2 = pd.DataFrame({ "A": [1, np.nan, 3, np.nan], "B": [4, np.nan, 6, np.nan], "C": [7, 8, 9, np.nan] }) print("---- how='all',整行全空才删除 ----") print(df2.dropna(how="all"))运行结果:
A B C 0 1.0 4.0 7.0 1 NaN NaN 8.0 2 3.0 6.0 9.0第3行(索引3)A、B、C全是NaN,只有它符合how="all"的条件,于是被删除。索引1那种A、B都是NaN但C有值的情况,不会被删。这个参数在清洗“整条记录都是空”的脏数据时非常实用,比如从Excel导入时末尾多出来的空行,直接dropna(how="all")就能清除。
how="all"配合axis=1还有另一个用途:删掉整列全空的空列。这个在读取某些结构混乱的表格时特别好用,比如表头后面跟了几列合并单元格导致的空列,直接在读完之后清洗掉。
3.3 thresh参数:用“至少保留多少个非空值”来控制删除
thresh是很多教程一笔带过但我认为是dropna()里最妙的一个参数。它的含义是:这一行/列至少要保有多少个非空值,如果达不到这个阈值就删掉。它和how的区别在于,它是一种带容忍度的删除策略,你允许某些样本缺几个字段,但不允许缺得太离谱。
df3 = pd.DataFrame({ "学号": ["001", "002", "003", "004"], "语文": [90, np.nan, 85, np.nan], "数学": [88, 92, np.nan, np.nan], "英语": [95, 89, 91, np.nan] }) print("---- thresh=3:至少保留3个非空值。索引0保留,索引1/2/3删除 ----") print(df3.dropna(thresh=3))运行结果:
学号 语文 数学 英语 0 001 90.0 88.0 95.0 1 002 NaN 92.0 89.0 2 003 85.0 NaN 91.0 3 004 NaN NaN NaN ---- 处理后 ---- 学号 语文 数学 英语 0 001 90.0 88.0 95.0 1 002 NaN 92.0 89.0 2 003 85.0 NaN 91.0学号+语文+数学+英语一共4列,thresh=3要求一行至少3个非空值。索引1缺语文但还有学号、数学、英语,凑够3个,留下了;索引2同理;索引3只剩一个学号,这行数据对分析来说基本没用,删除。这种用法在真实项目里非常常见——处理用户行为日志时,有些行只有设备ID,其他行为指标全空,留着只会拉低统计质量。
我踩过的坑是:thresh算的是非空值个数,不是空值个数。刚接触时我总想“允许每行缺2个”,直接把thresh设为列数减2,但其实你该设成“最少保留的字段数”,想清楚再写。
3.4 subset参数:只在指定的列范围内判断空值
subset接受一个列名列表,意思是我只关心这些列里有没有空值,只有在指定列中出现空值时才删除该行。它解决的是一个非常典型的业务问题:整行数据都完整,但某几个关键字段缺失,其他次要字段缺失无所谓。
df4 = pd.DataFrame({ "订单号": ["A001", "A002", "A003", "A004"], "金额": [100, 200, np.nan, 400], "备注": ["正常", np.nan, "已催单", np.nan] }) print("---- subset=['金额']:只看金额列是否为空 ----") print(df4.dropna(subset=["金额"]))运行结果:
订单号 金额 备注 0 A001 100.0 正常 1 A002 200.0 NaN 3 A004 400.0 NaN索引2因为金额是NaN被删掉了,索引1的备注虽然是NaN但金额存在,所以保留下来。在业务里这就是“备注可填可不填,但有订单金额记录才是一笔有效订单”的逻辑。这种删除策略非常精准,不会误伤其他字段缺失的行。
subset也可以传多个列名,比如dropna(subset=["金额", "客户ID"], how="any")表示只要金额或客户ID任一为空就删,因为这两列是订单的核心字段。可以说,subset让你从“全行缺陷”的判断升级为“关键字段缺陷”的判断,更贴合数据业务。
3.5 inplace参数:为什么我不推荐你用它
inplace=True会直接修改原DataFrame,不返回新对象。听起来很方便,但实际上有三大问题。
第一,它会破坏链式操作的流畅性。数据分析过程中你往往要连续做多个处理,dropna().reset_index()这种链式写法很舒服,但你在中间加一个dropna(inplace=True)就断掉了链子。第二,它让排查问题变困难。你处理完数据后过了几小时,想确认原数据到底有没有被动过,如果没有保留原始副本,可能已经忘记了当时的改动过程。第三,pandas官方自己也越来越不推荐这个参数,未来的版本有废弃计划。
我的建议是始终使用df_new = df.dropna(...)这种返回新对象的方式。如果担心内存问题,直接在读取数据后df = df.dropna()重新赋值就行,效果一样但代码更清晰。
4. fillna()函数:从简单填充到分组填充,层层递进
4.1 最基础的固定值填充:value参数
如果说dropna()是“减法”,那fillna()就是“加法”。它最基础的用法就是传一个固定的值进去,把所有空值替换成这个值。传标量时对整张表生效,传字典可以对不同列指定不同的填充值。
df5 = pd.DataFrame({ "售价": [100, np.nan, 300, np.nan], "折扣": [0.8, 0.9, np.nan, 0.85] }) print("---- 全表用0填充 ----") print(df5.fillna(0)) print("---- 用字典分别指定各列填充值 ----") print(df5.fillna({"售价": -1, "折扣": 1.0}))运行结果:
---- 全表用0填充 ---- 售价 折扣 0 100.0 0.80 1 0.0 0.90 2 300.0 1.00 3 0.0 0.85 ---- 用字典分别指定各列填充值 ---- 售价 折扣 0 100.0 0.80 1 -1.0 0.90 2 300.0 1.00 3 -1.0 0.85固定值填充适用于哪些场景呢?我举几个实际例子。数值型字段:如果字段本身表示计数或金额,空值可能意味着“没有发生”,填充为0是合理的。类别型字段:可以填充"未知"或"other",保留“存在但不知道”这个信息。标志位字段:填充0或False即可。但请注意,用0填充均值类连续特征可不是一个无脑操作,如果这个字段之后要进入计算,0值会让均值被拉低,后面还要配合特征工程调整。
4.2 用统计量填充:均值、中位数、众数的选择逻辑
用固定值太粗糙,很多人会想到用统计量填充。常用的三个统计量是均值(mean)、中位数(median)、众数(mode)。选择哪个不是靠抛硬币,而是看数据的分布特征。
- 均值适合数据接近正态分布或对称分布、没有极端异常值的情况。比如身高、考试成绩这类分布相对正常的字段,用均值填充对整体分布影响较小。
- 中位数适合数据偏态严重或存在明显异常值的情况。比如收入、房价、订单金额,这种长尾分布的数据,中位数比均值稳健得多——几个高净值用户能瞬间把均值拉上天,中位数却不为所动。
- 众数适合类别型字段,比如城市、职业、支付方式。众数就是出现次数最多的值,用它填充等价于“用最大概率猜测缺失的类别”。
df6 = pd.DataFrame({ "用户等级": ["普通", "VIP", "普通", np.nan, "普通", "VIP", np.nan], "月消费": [200, 800, np.nan, 3500, 120, 650, 5000] }) level_mode = df6["用户等级"].mode()[0] spend_median = df6["月消费"].median() print("用户等级众数:", level_mode) print("月消费中位数:", spend_median) df6["用户等级"] = df6["用户等级"].fillna(level_mode) df6["月消费"] = df6["月消费"].fillna(spend_median) print(df6)运行结果:
用户等级众数: 普通 月消费中位数: 650.0 用户等级 月消费 0 普通 200.0 1 VIP 800.0 2 普通 650.0 3 普通 3500.0 4 普通 120.0 5 VIP 650.0 6 普通 5000.0这里“月消费”的分布里有3500和5000这种明显偏高的值,均值会被拉得很高,事实上你可以算一下,(200+800+3500+120+650+5000) / 6 = 1711.67,而中位数只有650。如果这个字段用来给用户画像,用均值填充会把普通用户误判成高消费人群,用中位数则稳妥得多。
一个小细节:mode()返回的是Series,因为数据里可能出现多个并列众数。取第一个用[0],如果你用的是新版本pandas也可以用.iloc[0]。别直接写df6["用户等级"].mode()塞进fillna()里,类型对不上会报错。
4.3 method参数:前后向填充的适用场景
method参数是fillna()里的一个特殊把手,取值"ffill"(forward fill,前向填充)和"bfill"(backward fill,后向填充),默认是None。前向填充就是拿缺失值上面的值去补,后向填充则是拿下面的值去补。
df7 = pd.DataFrame({ "时间": ["09:00", "09:10", "09:20", "09:30", "09:40"], "温度": [23.5, np.nan, np.nan, 25.1, np.nan] }) print("---- 前向填充 ----") print(df7.fillna(method="ffill")) print("---- 后向填充 ----") print(df7.fillna(method="bfill"))运行结果:
---- 前向填充 ---- 时间 温度 0 09:00 23.5 1 09:10 23.5 2 09:20 23.5 3 09:30 25.1 4 09:40 25.1 ---- 后向填充 ---- 时间 温度 0 09:00 23.5 1 09:10 25.1 2 09:20 25.1 3 09:30 25.1 4 09:40 NaN前向填充最典型的场景是时间序列数据。比如传感器每隔10分钟上报一次温度,中间有几条记录在上传时丢包了,那用前一条记录的值补上比用全表均值合理得多,因为温度在短时间内是连续变化的。后向填充也有用武之地,比如某个指标的缺失是因为设备晚了几拍才上报,用后续的值来回溯补充也有一定逻辑。
需要特别注意的是:新版本pandas已经把method参数标记为“即将废弃”,现在的推荐写法是直接用fillna(method="ffill"),过渡期还能用,但新版里建议使用df.ffill()和df.bfill()这两个独立方法。我自己写代码时已经全面转用ffill()和bfill()了,语义更清晰,也免得到时候升级库时代码报错。
limit参数可以限制填充的连续空值个数,比如df.fillna(method="ffill", limit=1)表示最多只向后补1个空值,超过的保持NaN。这个参数在传感器连续采集场景里特别实用——设备断电半小时的连续缺失,如果一路用断电前的值硬撑,反而会掩盖故障,限制填充个数能保留“数据其实不可信”的信号。
4.4 分组填充:用同类数据的信息去补缺失值
分组填充是我认为fillna()使用中价值最高、最容易被忽视的一种玩法。它的思路是:全局均值未必能代表每个子群的特征,但按某些条件分组后再填充,每个组的统计量才更贴近组内真实水平。
举个最常见的例子:假设我们有一份多个城市的房价数据,“价格”列有一些空值。如果直接用全局均价填充,北京的空值会被同化成县级市的水平;如果按“城市”分组,分别计算每个城市的均价,然后用对应城市的均价填充,信息替换的精度就完全不一样了。
df8 = pd.DataFrame({ "城市": ["北京", "北京", "上海", "上海", "广州", "广州"], "房价": [80000, np.nan, 65000, np.nan, 45000, np.nan] }) city_avg = df8.groupby("城市")["房价"].transform("mean") print("分组均值:") print(city_avg) df8["房价"] = df8["房价"].fillna(city_avg) print("---- 分组填充后 ----") print(df8)运行结果:
分组均值: 0 80000.0 1 80000.0 2 65000.0 3 65000.0 4 45000.0 5 45000.0 Name: 房价, dtype: float64 ---- 分组填充后 ---- 城市 房价 0 北京 80000.0 1 北京 80000.0 2 上海 65000.0 3 上海 65000.0 4 广州 45000.0 5 广州 45000.0关键代码是groupby("城市")["房价"].transform("mean")。transform会把每组算出来的均值映射回原始的每一行,这样得到的Series长度和原DataFrame一样,可以直接传给fillna()。如果你用groupby("城市")["房价"].mean(),得到的是每个城市的均值列表,长度不对,没法直接填充。
更进阶一点,分组填充可以和多个条件配合:按“城市+户型”分组、按“用户等级+会员类型”分组,分组粒度越细,填充值的针对性越强。但也要警惕分组过细导致某些组只有一两行数据,统计量太不稳定。我踩过的坑是:某组所有房价都是NaN,transform("mean")算出NaN,填充进去依然是NaN,等于白忙。遇到这种情况要提前检查各组非空数量,或者用min_count这类参数兜底。
4.5 其他填充方式:插值法、apply自定义填充
有些场景连分组填充都还不够灵活,比如时间序列中温度变化的曲线是平滑的,用正上方的值硬填会导致数据出现台阶感。这时可以用interpolate()做线性插值,它是pandas里比fillna()更精细的补充手段,本质上是利用前后已知点拟合一条直线,然后估算中间缺失点的值。
s = pd.Series([1.0, np.nan, np.nan, 4.0, np.nan, 6.0]) print("---- 线性插值 ----") print(s.interpolate())运行结果:
0 1.0 1 2.0 2 3.0 3 4.0 4 5.0 5 6.0 dtype: float641和4中间的两个空值依次填充成2、3,4和6中间的填成5,非常自然。interpolate()的method参数还有很多选项,比如"polynomial"可以做多项式插值,"time"适合时间索引数据,但初学者先把线性插值用好就足够了。
如果所有内置方法都无法满足需求,你还可以配合apply或自定义函数做更复杂的填充逻辑。比如用df["列"].apply(lambda x: 业务规则填充(x)),只要函数能接收一个值、输出一个替代值就行。但注意,fillna本身不接受函数作为参数,所以自定义逻辑要么先算出完整的填充值Series再传给fillna,要么用apply逐元素处理。前者更符合pandas的向量化风格,性能和代码可读性都更好。
5. 实战场景:从“两张表”看空值处理的完整流程
5.1 场景设计:一份不完美的订单数据
为了把前面的知识串起来,我模拟一份电商订单数据,里面包含了多种常见的缺失情况:整行全空的脏数据、部分字段缺失、时间序列缺失、类别字段缺失。
df_order = pd.DataFrame({ "订单号": ["D001", "D002", "D003", "D004", "D005", "D006", "D007"], "用户ID": ["U1", "U2", np.nan, "U4", "U5", "U6", np.nan], "订单金额": [120, np.nan, 350, 80, np.nan, 999, 200], "支付方式": ["微信", "支付宝", "微信", np.nan, "微信", "银联", "支付宝"], "下单时间": pd.to_datetime(["2024-01-01 10:00", "2024-01-01 10:05", "2024-01-01 10:10", "2024-01-01 10:15", "2024-01-01 10:20", np.nan, "2024-01-01 10:30"]) }) print(df_order)运行结果:
订单号 用户ID 订单金额 支付方式 下单时间 0 D001 U1 120.0 微信 2024-01-01 10:00:00 1 D002 U2 NaN 支付宝 2024-01-01 10:05:00 2 D003 NaN 350.0 微信 2024-01-01 10:10:00 3 D004 U4 80.0 NaN 2024-01-01 10:15:00 4 D005 U5 NaN 微信 2024-01-01 10:20:00 5 D006 U6 999.0 银联 NaT 6 D007 NaN 200.0 支付宝 2024-01-01 10:30:005.2 清洗流程的分步决策
第一步,先统计空值分布:
print(df_order.isnull().sum())运行结果:
订单号 0 用户ID 2 订单金额 2 支付方式 1 下单时间 1 dtype: int64第二步,逐列做决策。
- 订单号无空值,作为主键字段明确保留。
- 用户ID有2个空值:这些订单没有绑定用户,后续做用户画像时无法归因,考虑直接删除这两行。当然,如果业务上允许匿名下单,填充
"匿名用户"也行,但这里我按“需用户维度分析”场景判断,删除。 - 订单金额有2个空值:这是核心指标。一个办法是用非空订单金额的中位数填充,另一个办法是删除。这里我倾向填充,因为删除会让这部分订单完全无法参与GMV分析。用中位数而非均值,因为999这个明显偏高,均值会被拉大。
- 支付方式有1个空值:类别字段,用众数填充。
- 下单时间有1个空值:时间序列上下文中有相邻订单的下单时间,可以用前后时间做插值填充或前向/后向填充。这里
NaT是时间类型的空值标记,fillna同样能处理。
第三步,按顺序执行:
# 先删除用户ID为空的订单(因为它们是无效样本) df_order_clean = df_order.dropna(subset=["用户ID"]).copy() # 金额用中位数填充 amount_median = df_order_clean["订单金额"].median() # 支付方式用众数填充 pay_mode = df_order_clean["支付方式"].mode()[0] df_order_clean["订单金额"] = df_order_clean["订单金额"].fillna(amount_median) df_order_clean["支付方式"] = df_order_clean["支付方式"].fillna(pay_mode) # 下单时间用前向填充,尽量贴近相邻时间 df_order_clean["下单时间"] = df_order_clean["下单时间"].fillna(method="ffill") print(df_order_clean)运行结果:
订单号 用户ID 订单金额 支付方式 下单时间 0 D001 U1 120.0 微信 2024-01-01 10:00:00 1 D002 U2 200.0 支付宝 2024-01-01 10:05:00 3 D004 U4 80.0 微信 2024-01-01 10:15:00 4 D005 U5 200.0 微信 2024-01-01 10:20:00 5 D006 U6 999.0 银联 2024-01-01 10:20:00看一下这个过程中有意思的细节:金额中位数算出来是200,这个值同时填充了D002和D005这两笔缺失订单。支付方式的众数是“微信”,因为非空值里微信出现了3次,支付宝2次,银联1次,于是D004被填成“微信”。下单时间用的是前向填充,D006的缺失时间被填成了上一行D005的时间10:20,严格说它不是真实的支付时间,但作为分析近似值还是可以接受的。
这里有个非常关键的教训:我特别加了.copy(),目的是防止后续操作出现SettingWithCopyWarning。如果你写df_order.dropna(subset=["用户ID"])然后直接在上面改动,某些pandas版本可能警告你“正在试图修改一个来自切片的数据副本”,运行结果偶尔会出乎意料。养成赋值时加copy()的习惯,能省掉很多莫名其妙的调试时间。
5.3 顺序不同,结果不同
空值处理的顺序也会影响最终结果。比如先填充下单时间再用用户ID做去空操作,和先删再填,最终数据行的效果通常一致,但某些填充统计量会变。还是金额的例子:如果你先删除用户ID为空的D003和D007,再算金额中位数,[120, 350, 80, 999, 200]的中位数是200;但如果你不对用户ID做任何删除,直接算所有订单金额的中位数,[(120, 350, 80, 999, 200)]加两个NaN,pandas默认会跳过NaN,中位数还是200,这次恰好一样。但换一组数据就不一定了,比如空值集中在大额订单上,删除行后计算统计量和删除前计算统计量会有本质差别。
实际操作上,我个人的顺序习惯是:先做行级删除(按关键字段去空),再做列级删除(按全空/高缺失比例),最后做填充。因为填充策略里的统计量基于的是“即将进入分析的数据集”,如果某些行注定被删,那它们包含的字段值就不该参与统计量的计算。反过来,如果你先填充再删除,填充计算时混入了脏行的其他字段信息,污染会传导到统计量里。
6. 那些容易踩的坑:检查NAN的方式、链式索引和内存隐患
6.1 判断空值别用“== np.nan”
很多初学者会写出df[df["列名"] == np.nan]这样的代码,然后发现筛选结果为空。原因很简单,NaN在Python中的比较行为很特殊,它不等于任何值,包括它自己。就像你去问一个失忆者“你是张三吗?”他回答不是,问“你是李四吗?”也不是,问“你是你自己吗?”它还是说不是,因为NaN本身就是一个“未知”的标记。
正确判断空值必须用pd.isna()或isnull():
print(df5[df5["售价"].isna()])这条代码才能准确筛出“售价”为空的行。类似的还有df["列"].notna()等方法。不要小看这个区别,我见过有人排查了半天为什么空值筛不出来,最后发现就是错用了等号。
6.2 处理完空值之后一定要重置索引
dropna()删除行后,DataFrame的索引会出现“空洞”,索引序列变成0、1、3、5这种跳跃形式。很多人后续做循环遍历或按索引切片时,会莫名其妙地报KeyError,排查方向却弄了半天。
解决办法非常简单:
df_clean = df.dropna().reset_index(drop=True)reset_index(drop=True)的作用是让索引从0开始重新连续编号,并且不保留原来的索引列。如果忘了写drop=True,pandas会把旧索引作为一列新数据加进来,这在某些场景下同样令人困惑。我现在已经形成了肌肉记忆:凡是删过行的DataFrame,下一步十有八九是reset_index(drop=True)。
6.3 大DataFrame的内存问题:复制是必要代价
有人听我说copy(),第一反应是浪费内存。这个担忧可以理解。但实际上,pandas的copy()在多数场景是浅拷贝,底层数据共享,并不会把几千万行数据完全复制一份,内存开销通常可控。反而是不写copy()时,那些在切片视图上做的隐形赋值逻辑才会在数据处理链路过长时给你埋雷。
另一个内存相关的建议是:如果数据量巨大,先做dropna这类“删”的操作,再做fillna这类“填”的操作。删除会尽早缩小DataFrame体积,后续填充花费的时间更少。反过来,先把所有空值都填了再删除,等于白算了一大批填充值。
6.4 处理“空字符串”和“真正的空值”的区别
原始数据里经常有看起来像空值的空白字符串,比如Excel导入后Excel空单元格有时被pandas读成NaN,但某些系统导出时会把空单元格写成""、" "甚至"未知"。isna()只会识别NaN、None、NaT,不会把空白字符串判断为空值。
遇到这种情况,我一般先用df.replace("", np.nan, inplace=False)把空字符串转成真正的NaN,然后再用dropna()或fillna()统一处理。如果数据里存在多种形式的伪空值,可以串多个replace,或者用一个更暴力的方法:df = df.apply(lambda x: x.str.strip() if x.dtype == "object" else x)把字符串首尾空格去掉,再做替换。办法是按数据情况灵活组合,关键是意识到“看着空不等于真的空”。
7. 几个直接可以拿去用的封装函数
7.1 一个简易的缺失值报告函数
每次处理数据前我都要看缺什么、缺多少,于是我写了一个非常简短的报告函数,几行代码就能输出每列的缺失情况。把这套逻辑固化成工具,每次拿到新表直接调用,可以省去大量重复劳动。
def missing_report(df): """返回DataFrame中每列的缺失值数量与比例""" missing_count = df.isnull().sum() missing_ratio = missing_count / len(df) report = pd.DataFrame({ "缺失数量": missing_count, "缺失比例": missing_ratio.round(4) }) return report[report["缺失数量"] > 0].sort_values("缺失比例", ascending=False) # 用法示例 print(missing_report(df_order))运行结果:
缺失数量 缺失比例 用户ID 2 0.2857 订单金额 2 0.2857 支付方式 1 0.1429 下单时间 1 0.1429一个表里如果有多列缺失比例差异很大,这个报告能帮你一眼锁定问题字段。我通常会在报告基础上决定:超过70%的直接删列,低于10%的直接填,中间的再做人工判断。
7.2 一个“理解业务”的填充函数模板
下面的工具函数是我在几个项目里反复用过的模板,它结合了groupby分组填充和可选的填充值策略,日常处理多字段缺失时非常有效率。它本质上是一个策略配置器:哪些列用均值、哪些列用中位数、哪些列要按某个分组键来分组填充。
def smart_fillna(df, fill_rules): """ df: 待处理的DataFrame fill_rules: 字典,键是列名,值是填充策略。 策略可以是: - 一个固定值 - "mean" / "median" / "mode" - ("group", 分组列名, "mean" / "median" / "mode") """ df_filled = df.copy() for col, rule in fill_rules.items(): if col not in df_filled.columns: continue if isinstance(rule, tuple) and rule[0] == "group": group_cols = rule[1] method = rule[2] if method == "mode": def mode_func(s): return s.mode()[0] if not s.mode().empty else np.nan fill_series = df_filled.groupby(group_cols)[col].transform(mode_func) else: fill_series = df_filled.groupby(group_cols)[col].transform(method) df_filled[col] = df_filled[col].fillna(fill_series) elif rule == "mode": mode_value = df_filled[col].mode()[0] df_filled[col] = df_filled[col].fillna(mode_value) elif rule in ("mean", "median"): stat_value = getattr(df_filled[col], rule)() df_filled[col] = df_filled[col].fillna(stat_value) else: df_filled[col] = df_filled[col].fillna(rule) return df_filled用法示例:
df_final = smart_fillna(df_order, { "订单金额": "median", "支付方式": "mode", "用户ID": "未知用户", "下单时间": ("group", ["支付方式"], "median") # 假设可对时间取中位数 })这个模板的精髓是把“哪些列用哪种策略”集中在一个可读性很强的字典里,以后数据更新了,只要改字典就能跑出一条新流程。数据清洗代码最忌讳的是一堆散落的fillna()散落在各处,最后的可维护性会变得很差。
8. dropna()和fillna()之外的“邻居”方法
8.1 drop_duplicates():重复值往往比空值更隐蔽
处理缺失值时,我经常会顺手检查一遍重复值,因为很多数据里重复记录和缺失记录相互纠缠。比如同一个订单出现两行,一行金额有值,一行金额为空;如果直接dropna()可能会把两行都留下,但它们其实是重复数据。
drop_duplicates()用法很直观:
df_dup = pd.DataFrame({ "订单号": ["D001", "D001", "D002", "D003"], "金额": [100, 100, np.nan, 300] }) print(df_dup.drop_duplicates(subset=["订单号"], keep="first"))运行结果:
订单号 金额 0 D001 100.0 2 D002 NaN 3 D003 300.0在清洗流程里,我习惯先做去重再做空值处理,这样可以避免“重复行的一行填了、另一行没填”这类不一致问题。keep="first"表示保留第一次出现的行,keep="last"保留最后一次,如果你想知道哪些行被删了,可以用keep=False配合indicator=True先观察。
8.2 replace()和where():条件替换的灵活组合
fillna()只能针对空值,但现实数据里经常需要把特定值也看作“伪空值”,或者把某些不合理的值替换成空值再做统一处理。这时replace()就能派上用场。
df9 = pd.DataFrame({ "评分": [5, 0, 3, 0, 4, -1] }) # 把0和-1这类异常评分统一替换成NaN df9["评分"] = df9["评分"].replace([0, -1], np.nan) print(df9)运行结果:
评分 0 5.0 1 NaN 2 3.0 3 NaN 4 4.0 5 NaN替换成NaN之后,就可以衔接fillna()做后续处理了。where()的用法则是换了一个角度:它保留满足条件的值,不满足条件的替换成指定值,等价于“条件为假时填值”。实际项目中where()用得相对少一点,但组合起来解决“特定条件下的空值填充”非常灵活,比如“只对当天的数据做填充,历史数据不动”。
8.3 isna()与notna():掩码取用的实战价值
除了统计,isna()还经常用来做条件筛选和布尔索引。有人会问,df[df["列"].isna()]筛选出来的行我想直接查看或者导出人工核对,该怎么操作?很简单,把它赋值给一个新变量或者写入Excel。
null_rows = df9[df9["评分"].isna()] print(null_rows)这类掩码操作在数据质量报告、异常样本核查中非常常见。数据清洗不是一股脑执行完就结束,偶尔需要把可疑样本拎出来人眼检查。掌握了isna()的布尔索引,就等于掌握了“在数据里圈出问题区域”的能力。
9. 为什么我会建议你形成一套自己的空值处理流程
在写了大量数据处理脚本之后,我最大的体会是:dropna()和fillna()并不是彼此替代的关系,而是一条清洗流水线上的两个工位。它们的共同目标不是“消灭空值”,而是“让剩下用于分析的数据尽量真实地反映业务”。
我的个人处理流程也分享出来供参考:
- 先
drop_duplicates()去重,让每条样本只出现一次。 - 输出缺失值报告,看每一列
isna()的数量和比例。 - 删掉全空列、全空行,用
dropna(axis=1, how="all")和dropna(axis=0, how="all")。 - 对关键字段定义“必须非空”的约束,用
dropna(subset=[...])删除不符合要求的行。 - 对剩余字段分组填充或统计量填充,优先考虑业务分组,其次中位数,再其次均值。
- 时间序列字段的缺失用
ffill或interpolate()。 - 最后
reset_index(drop=True),再输出一次缺失值报告,确认不再有遗漏。
这套流程并不总是最优,但它覆盖了绝大多数通用场景。如果你的数据是深度学习训练集,填充策略可能还要更慎重,甚至要设计专门的缺失值指示变量;如果你的数据是线上实时特征流,清洗逻辑可能要封装成独立模块每天跑。但无论场景怎么变,底层逻辑都是同一个:先理解为什么缺失,再决定怎么处理,最后用报告验证处理结果。
我在实际项目里还形成过一个习惯:每次做完数据清洗,都会把“清洗前后行数变化”“每列填充了什么值/删除了多少行”记录到一个文本文件里,方便之后回溯。数据清洗这件事看起来是体力活,但一旦出了问题,没有记录的话排查成本极高。把这些机制用起来,再回头看dropna()和fillna(),它俩就已经不是冰冷的函数,而是你支撑数据分析结论可信度的两条拐杖了。