做数据分析的人,早晚都要跟缺失值打交道。不管你是用pandas处理Excel导出的报表,还是清洗爬虫抓下来的数据,抑或是整理一坨业务部门给的“原始底表”,第一步永远不是跑模型,而是把空缺值处理干净。fillna这个函数,说简单很简单,无非是给NaN填个值;但真到了实战里,填什么、怎么填、按行填还是按列填、填完会不会影响后续统计,这些都是有讲究的。
这篇我用一个相对完整的案例,把fillna从参数到实战拆开揉碎讲清楚。适合刚接触pandas的初学者,也适合已经有了基础但想系统梳理缺失值处理思路的分析师。读完你应该能做到:拿到一份带缺失值的数据,知道该用哪种填充策略,并且能用fillna写出干净、不出bug的清洗代码。
1. 动手填缺之前,先把这三件事想明白
1.1 缺失值到底是怎么来的
很多人一上来就df.fillna(0),这是最省事但也是最危险的做法。在动手填充之前,我建议你先花两分钟搞清楚缺失值的来源,因为不同来源的处理策略完全不同。
第一种是数据采集阶段的缺失。比如埋点没采到、网络传输丢包、传感器某个时间点没返回数值,这类缺失通常是无规律的,和业务本身的规律没有关系,一般可以用均值、中位数或插值来处理。
第二种是业务规则导致的缺失。比如某产品线是新上线的,历史数据根本没有;或者用户没有填写的可选字段。这类缺失本身携带了“这个值不存在”的信息,直接填0或者填均值反而会失真。
第三种是数据合并造成的缺失。merge之后某些列在部分行没有对应值,pivot之后产生大量NaN,这类缺失很多时候根本不需要填充,或者说填什么取决于下游要做什么。
我自己的习惯是:先看一眼缺失的分布和时间位置,做一个快速判断。数据量大、缺失率低(比如低于5%),直接删掉对应行往往更省事;缺失率高但列重要,那就得认真考虑填充策略;如果缺失列根本不参与分析,直接保留NaN反而最诚实。
1.2 不要忽略None和NaN的差别
用Python久了容易忽略一个细节:None和np.nan在pandas里表现相似,但底层的处理逻辑不完全一样。
None是Python原生的空对象,np.nan是浮点数类型的空值。pandas在读取数据时,遇到空值通常会统一转成np.nan,特别是当整列都是数值类型的时候。但如果你手动构造DataFrame,或者读取某些特殊的JSON数据,可能有列是object类型,里面混着None和字符串。
检查缺失时,df.isnull()和df.isna()是等价的,内部都会把None识别为缺失。真正容易出问题的是比较操作:用== np.nan永远返回False,因为NaN不等于任何值,包括它自己。判断缺失只能用isnull()。
另外一个实战细节:如果某列是字符串类型,正常缺失值是NaN,但有些数据源会把空字符串''当缺失值写进来。这时候isnull()不会把它识别为缺失,你需要在清洗时先做一步转换:
df['备注'] = df['备注'].replace('', np.nan)不先处理这一步,后面fillna对空字符串是不生效的,填了跟没填一样。
1.3 fillna在缺失值处理中的定位
处理缺失值有三大流派:删除、填充、保留。
删除对应dropna(),填充对应fillna(),保留就是不处理,让模型自己处理(很多树模型天然支持缺失值)。fillna只解决填充的问题,但填充本身又可以分为固定值填充、统计量填充、前后填充、插值填充。fillna负责前三种,插值要交给interpolate()。
我的经验是:fillna适用于缺失率不高、且你能找到合理填充依据的场景。如果某列缺失率超过30%,任何填充方式本质都是在编造数据,这时候更建议把它作为单独的缺失标记列处理,或者直接删除。
2. fillna函数参数逐项拆解
2.1 value:最常用也最容易被误解的参数
fillna的第一个核心参数是value,它决定了你要填什么。很多人以为value只能传一个常数,比如df.fillna(0),其实它可以传的值类型非常多:
- 标量:所有缺失位置填同一个值
- 字典:按列指定不同的填充值
- Series:按索引对齐后填充
- DataFrame:按行列对齐后填充
字典传参是实战中最常用的进阶写法:
df.fillna({ '销量': 0, '单价': df['单价'].median(), '备注': '暂无' })这个写法一次调用解决了多列不同类型字段的填充需求,比写多次fillna要清晰得多。需要注意的一点是:字典的key必须和列名完全一致,否则pandas不会报错,只是静默地不填充那一列——这种“隐形失效”我踩过不止一次,现在的习惯是填充前检查一下每列的缺失值数量是否真的减少了。
Series传参的典型场景是按另一个字段对齐填充。比如产品A的缺货数量,可以用同产品在另一个表中的库存量作为Series来填,这样比全局均值要合理得多。
2.2 method:前向填充和后向填充的适用场景
method参数是fillna里最体现“懂不懂pandas”的部分。它接受两个值:ffill(前向填充)和bfill(后向填充)。
前向填充的含义是:用缺失值前面最近的一个非缺失值来填充。典型的应用场景是时序数据,比如股票数据的停牌日、设备运行记录中某几个时间点没采集到数据,这时候相邻时刻的值通常是最合理的参考。后向填充正好相反,用后面的非缺失值往前填。
# 前向填充:用上一条记录的值填充 df['收盘价'].fillna(method='ffill') # 后向填充:用下一条记录的值填充 df['收盘价'].fillna(method='bfill')新版pandas也支持函数式写法:df['收盘价'].ffill(),这其实是等价且更推荐的,因为method参数在很多pandas版本里已经被标记为deprecated。我在自己的代码里基本上都写成df['收盘价'].ffill(),可读性更好。
ffill有一个容易忽略的细节:如果序列开头就缺失,前向填充是填不了的,后面没有可参考的值,填充后仍然是NaN。这时候通常要结合bfill用一次,或者指定一个初始值。
2.3 axis与limit:控制填充方向和数量
axis参数决定沿着行方向还是列方向填充,这个很好理解。limit参数限制连续填充的最大数量,却是很多人不看文档就乱用然后出bug的地方。
limit限制的不是填充的总数量,而是连续缺失段内的填充次数。比如一个序列是[1, NaN, NaN, NaN, 5],ffill() + limit=2的结果是[1, 1, 1, NaN, 5]——只填了前两个空位,第三个保留NaN。这个设计逻辑是:连续缺失太多时,越靠后的缺失值用前向填充的参考价值越低,干脆保留为NaN提醒你注意。
在金融数据的处理中,limit非常实用。比如一个股票连续停牌5天,你只想为最多2天的缺失做前向填充,超过2天就保持缺失:
df['交易量'].fillna(method='ffill', limit=2)另一个需要留意的场景是配合axis=1使用。一般来说,我会在行方向(axis=0)使用ffill,在列方向(axis=1)使用的场景比较少见,但偶尔会遇到:调查问卷数据中,如果受访者漏填了某道题,可以用同一行前一列的答案来填充——这在逻辑上是否合理取决于具体业务,不能一概而论。
2.4 inplace:原地修改还是返回副本
inplace=True意味着直接在原DataFrame上修改,不返回新对象;inplace=False(默认)返回一个新对象。很多人图省事写df.fillna(0, inplace=True),我建议你改掉这个习惯。
原因有三个:第一,默认的返回新对象方式可以链式调用,代码更简洁;第二,inplace操作在某些pandas版本中对被切片视图不一定生效,容易遇到SettingWithCopyWarning;第三,从代码可读性的角度,df = df.fillna(0)这个写法在任何Python环境下行为都明确,不容易产生歧义。
我自己的风格是:小数据集怎么写都行,但清洗脚本一律用df = df.fillna(...),统一风格降低出问题的概率。
3. 案例实战:一份销售数据的完整清洗演示
3.1 构造一份带各种缺失脏数据
理论知识说完了,直接上案例。我模拟一份某公司各区域门店的销售数据,包含门店编号、所属大区、产品类别、销售数量、单价、销售额、店长评分、备注这几列。故意在里面埋了不同类型的缺失:
import pandas as pd import numpy as np data = { '门店编号': ['S01', 'S02', 'S03', 'S04', 'S05', 'S06', 'S07', 'S08', 'S09', 'S10'], '大区': ['华东', '华东', '华南', '华南', '华北', '华北', '西南', '西南', None, '东北'], '产品类别': ['数码', '家电', '数码', '服饰', '美食', '美食', '家电', '数码', '服饰', '美食'], '销售数量': [120, 85, np.nan, 200, 60, np.nan, 95, 150, 30, 75], '单价': [3999, np.nan, 1499, 199, np.nan, 85, 2499, 4999, 299, 45], '销售额': [np.nan, 81600, 37900, 39800, 10200, np.nan, np.nan, 749850, 8970, 3375], '店长评分': [4.5, 4.2, np.nan, 3.8, 4.8, np.nan, 4.1, np.nan, 4.6, 4.0], '备注': ['促销', '', '缺货', '新品', None, '清仓', '退货', '', '正常', '促销'] } df = pd.DataFrame(data)这份数据里结合了数值列缺失、文本列空字符串、部分行整个缺失的情况,比较贴近真实的业务底表。拿到它,先做一次缺失分布检查:
df.isnull().sum()可以看到:销售数量、单价、销售额、店长评分、备注都存在缺失,大区列有一行缺失。第一步先处理备注列的空字符串,转成NaN后再统一看分布。
3.2 按照字段业务含义选择填充策略
现在逐列分析。不同列的缺失原因和可行的填充逻辑完全不同,这正好体现fillna的灵活度。
销售额这一列比较有意思。销售额和销售数量、单价存在乘法关系:销售额 ≈ 销售数量 × 单价。对于销售额为NaN的行,如果数量和单价都存在,直接算出来比任何填充都准确:
# 先备份原始数据 df_clean = df.copy() # 计算缺失销售额行的估计值:数量 × 单价 mask = df_clean['销售额'].isnull() df_clean.loc[mask, '销售额'] = df_clean.loc[mask, '销售数量'] * df_clean.loc[mask, '单价']这一步是把业务逻辑直接落到代码里,本质上已经不是单纯填充,而是基于约束关系的推算,比fillna更优。我建议你遇到“可计算字段”缺失时,优先考虑这种重建方式,填充永远是兜底方案。
销售数量的缺失,分两种情况。如果门店在该时间段内确实没有销售记录,数量应该是0;但这份数据里缺失的S03和S06是大区内的正常门店,合理的做法是用同产品类别的均值填充:
# 按产品类别分组填充均值 df_clean['销售数量'] = df_clean.groupby('产品类别')['销售数量'].transform( lambda x: x.fillna(x.mean()) )这里用transform的分组填充是个大杀器。它的含义是:对每个产品类别分别计算均值,然后用该均值填充该类别内的缺失值。比全局均值填充更合理,因为数码类产品的平均销量和美食类产品的平均销量显然不在一个量级。
单价的缺失也类似,但单价不适合用均值填。单价受产品类别影响极大,数码单价几千,美食单价几十,用同一产品类的中位数填充更稳妥,因为中位数不受极端值干扰:
price_median = df_clean.groupby('产品类别')['单价'].transform('median') df_clean['单价'] = df_clean['单价'].fillna(price_median)注意这里的写法:先算好每个类别的中位数并映射到每一行,再对缺失位置填充。和前面的lambda写法本质相同,但用命名列的方式可读性更好。
店长评分是主观评分字段,不建议用均值填充。我的选择是:如果门店有历史评分数据,用该门店自己的历史均值;如果没有,可以考虑保留NaN或用总体的中位数兜底。案例里我用总体中位数:
df_clean['店长评分'] = df_clean['店长评分'].fillna(df_clean['店长评分'].median())备注列,空了就填一个‘无备注’,因为它是文本说明字段,填充的只是占位意义,不影响分析逻辑:
df_clean['备注'] = df_clean['备注'].fillna('无备注')大区列,属于分类文本,缺失这一行正好是最后一行。最合理的填充是看它在数据里的位置,或者用门店编号推导。案例里门店编号S10缺失大区,而前面S09的大区是东北,这两个门店同区域的可能性很高,用ffill正好合适:
df_clean['大区'] = df_clean['大区'].ffill()这就是ffill在非时序数据中也有应用场景的典型例子——当数据本身按区域排列、相邻行有相关性时,前向填充可以用。
3.3 同一个操作写成三行和写成一行
上一个案例里,我逐个字段写了填充逻辑。实际做清洗的时候,更推荐把策略集中管理,用字典形式一次性传入fillna:
df_clean = df.copy() # 先单独处理销售额重建,这部分不走fillna mask = df_clean['销售额'].isnull() df_clean.loc[mask, '销售额'] = df_clean.loc[mask, '销售数量'] * df_clean.loc[mask, '单价'] # 集中处理:不同列用不同策略 df_clean = df_clean.fillna({ '大区': '未知', '销售数量': df_clean['销售数量'].mean(), '单价': df_clean['单价'].median(), '店长评分': df_clean['店长评分'].median(), '备注': '无备注' })但这种集中写法的前提是:所有列的填充策略都相对简单。一旦涉及到分组填充,就没法用单个字典一次性完成,还是要分步写。所以我的建议是:简单的字段用字典统一处理,需要分组统计的字段单独处理,这样代码不混乱,逻辑也清楚。
3.4 时序场景下的连续缺失处理案例
销售数据之外,我再给一个时间序列的场景,因为这是ffill和limit最常出场的舞台。
假设你有一份某门店逐日的客流数据,数据里连续几天缺失:
dates = pd.date_range('2025-01-01', periods=30, freq='D') values = np.round(np.random.default_rng(42).normal(500, 80, 30), 0) values[5:8] = np.nan values[15:20] = np.nan ts_df = pd.DataFrame({'日期': dates, '客流量': values})对这类数据,两个选择:
- 如果你的业务判断客流不会因为缺失几天而突变,用
ffill填充,limit根据缺失长度调整; - 如果连续缺失太多,
ffill会让客流量出现一条水平直线,看起来就不真实。这时候可以考虑interpolate()做线性插值,让中间的值平滑过渡。
fillna解决的是“引用前值”的问题,interpolate解决的是“在两个已知值之间推断”的问题。如果客流每天波动剧烈,用ffill会导致填充出的几天客流量完全相同,后续做趋势分析时会产生假平台。这时候我会优先用interpolate:
ts_df['客流量'] = ts_df['客流量'].interpolate(method='linear')fillna和interpolate不是二选一的竞争关系,而是要根据数据类型和缺失的形态来决定。我的经验是:真正的时序连续变量优先interpolate;离散状态、或需要严格引用上一条数据的场景用fillna的ffill。
4. fillna实战中躲不开的坑
4.1 填充之后列的数据类型变了
这是初学者最容易忽略的坑。当你用fillna(0)填充一个原本是整数类型的列时,整列会被自动提升为浮点类型。因为NaN是浮点类型的值,pandas为了保证列内数据类型一致,只能把整个列升级为float。如果下游逻辑依赖整数类型,可能会出问题。
填充后可以手动转类型:
df['销售数量'] = df['销售数量'].fillna(0).astype(int)但要注意:只有填充后确实没有小数、且没有NaN残留时,才能安全astype(int)。如果有残留的NaN,转int会直接报错。
4.2 method填的是方向,不是语义
ffill是按数据出现顺序填充的,pandas默认认为行索引的顺序就是时间顺序。如果你的数据没有按时间排序,ffill用起来就要非常小心。
举个例子:门店编号列如果乱序,前向填充会把上一条记录的值填到当前位置,但这个“上一条”在业务上可能毫无关系。在排序之前做ffill,结果一定是错的。
所以我的习惯是:任何使用ffill的场景,先做df.sort_values(时间列, inplace=True)或写df = df.sort_values(...),确保数据顺序对人有意义。
4.3 fillna不会帮你处理None的“隐身兄弟”
前面提过空字符串''不会被isnull()识别为缺失,这里再强调一次。文本列的空值和数值列的NaN不同,它不会被自动转换成NaN。如果不先做replace,填了等于白填,而且整列看起来还是有“空”的——实际上它是长度为零的字符串。
处理文本列的清洗建议:
# 把所有空字符串转NaN df['备注'] = df['备注'].replace('', np.nan) df['备注'] = df['备注'].fillna('无备注')4.4 groupby + fillna的链式写法和限制
分组填充很常用,但有些写法会让人误导。看下面这两种:
# 写法A:transform df['数量'] = df.groupby('产品')['数量'].transform(lambda x: x.fillna(x.mean())) # 写法B:groupby之后直接fillna(部分pandas版本已支持) df['数量'] = df.groupby('产品')['数量'].fillna(df['数量'].mean())写法B在较新的pandas版本里是支持的,但要注意第二个参数df['数量'].mean()计算的是全局均值,不是分组均值。想用分组均值,还是transform最可靠。很多老版本pandas不支持写法B,所以我的建议统一用transform。
4.5 填充完之后别忘了一个复核动作
无论按什么策略填充,填充完都应该做一次复核:
# 再次检查是否还有缺失 print(df_clean.isnull().sum()) # 抽查填充结果 print(df_clean[df_clean['销售数量'] == df_clean['销售数量'].max()])如果还有残留,排查一下是limit导致的,还是原始数据里存在空字符串或None混用。这一步虽然简单,但能避免很多下游分析时才发现的数据质量问题,省掉返工的时间。
5. 填充之外的两种高级思路
5.1 缺失值本身就是信息
有些场景下,“缺失”本身就是一个值得建模的特征。举个典型的例子:用户画像表里,是否实名认证、绑卡数量这些字段如果缺失,往往不代表没有,而是代表用户还没触发过这个行为。这时候把缺失值单独用一个标记位表示,可能比填充成任何数字都更有效。
简单做法:
df['是否有评分'] = df['店长评分'].notnull().astype(int)然后下游建模时把是否有评分作为特征,同时保留原始字段的填充值或不填充值,让模型自己利用这个信息。
5.2 多次填充的迭代思路
还有一种不太常用但在某些业务场景下很有效的思路:先做一次粗略填充,生成一个中间版本,再根据中间版本的结果做二次填充。比如:先用ffill填充销售数量,计算门店平均销量,再用这个平均值回填其他缺失行。
这种思路适合数据质量差但有明显业务规律的场景。不过它增加了代码复杂度,除非确实有收益,否则不建议在小项目中滥用。
最后说两句实在的
fillna这个函数本身极其简单,难的是填充之前的思考。你选择填0、填均值、填中位数还是填前值,本质上反映的是对业务的理解。我在实际项目中见过太多“统一fillna(0)”然后把整个分析带偏的案例——填充本身没错,错的是填充值的选择缺少依据。
我个人在数据清洗环节有一个固定的工作流:先统计缺失分布,再逐列判断缺失业务含义,最后统一写清洗函数。每个字段用了什么填充逻辑,全部用注释标清楚。这样代码跑完,任何人回头检查都能看懂当初为什么这么填。数据清洗这件事,不怕慢,就怕不清不楚。希望这篇文章里的案例和踩坑经验,能帮你把fillna用得明明白白,在下次拿到脏数据时少走几步弯路。