搞数据分析、写爬虫、做量化,只要是跟表格数据打交道,就没有不跟缺失值打照面的。python填充空缺值这事,听起来简单,做起来门道不少——直接dropna()删掉吧,怕丢信息;全填均值吧,怕偏;填0吧,业务上又讲不通。这篇文章就是想把填充缺失值这件事从头到尾捋一遍,从 pandas 的基础操作到 sklearn 的进阶玩法,从手动填到自动补,把每个方案的适用场景、实现代码、常见的坑都摊开讲。适合刚接触 pandas 的零基础新手,也适合天天跟脏数据打交道的分析岗、算法岗朋友参考。
数据里的空缺值就像房子里的裂缝,不处理会越拖越大。但处理之前你得先搞清楚:裂缝是承重墙上的还是隔断墙上的?是天生就漏还是后天才漏的?所以我习惯把填充缺失值分成三步:摸底细、选策略、做验证。下面一个一个说。
1. 先摸透缺失值的"脾气",再谈填充
1.1 缺失值不是一种东西:类型与成因
很多人一上来就fillna(0),这是最容易翻车的操作。缺失值至少分三种:
- 完全随机缺失(MCAR):某个值是否缺失跟其他任何变量都无关,纯粹是意外。比如系统录入时断电、问卷少勾了一题。
- 随机缺失(MAR):缺失跟当前字段本身无关,但跟其他字段有关。比如女性受访者更少填写收入——收入字段缺失,但"性别"字段解释了为什么缺失。
- 非随机缺失(MNAR):缺失的原因跟这个字段本身的值有关。比如收入极高的人故意不填收入,那"收入缺失"本身就是一个信息。
怎么判断?没有100%的数学检验,但可以从业务角度先琢磨:这个数据是怎么来的?人工录入、传感器采集、日志解析?不同成因决定了你后面选策略的基调。传感器数据用插值很合理,人工录入用均值补齐就要小心。
另一个容易忽略的点:NaN只是 pandas 里缺失值的一种表现形式。None、pd.NaT、甚至字符串'NA'、'null'、空字符串'',肉眼看着都像"空",但在 pandas 里性质完全不同。我见过太多次因为空字符串''不是NaN,导致fillna完全没生效的案例。
1.2 动手填充前,先回答三个问题
我每次做数据清洗,都会强迫自己过一遍以下三个问题:
- 这个字段的业务含义是什么?销售额填0和库存填0完全是两码事。销售额缺失填0会拉低均值,库存缺失填0可能意味着"没货"而非"数据丢了"。别用统一策略糊弄所有列。
- 缺失比例有多大?如果某列缺失90%,任何填充策略都是在编数据。这时候不如直接当"该列不适合做特征"处理,要么删列,要么把"是否缺失"本身做成一个二值特征(这招在机器学习特征工程里很好用)。
- 这份数据后续要干嘛?如果只是做报表统计,均值中位数够用;如果要建预测模型,填充方式会直接影响模型筛选特征的顺序;如果做时间序列分析,数据的时间顺序就是最高优先级,只能用上下文插值。
这三个问题想清楚了,再决定用均值、中位数、众数、常数、前后向填充还是模型预测填充。方案没有绝对好坏,只有适不适合当下的数据场景。
1.3 填充策略怎么选:一张对照表看清适用场景
| 填充方式 | 典型代码 | 适用场景 | 注意点 |
|---|---|---|---|
| 常数填充 | fillna(0)/fillna('未知') | 业务上"缺失=无" | 会拉低方差,模型特征慎用 |
| 均值填充 | fillna(df['col'].mean()) | 数值型,数据近似正态分布 | 对异常值敏感,先看分布 |
| 中位数填充 | fillna(df['col'].median()) | 数值型,有离群点 | 比均值稳健 |
| 众数填充 | fillna(df['col'].mode()[0]) | 分类变量 | 多众数时按业务挑一个 |
| 前向/后向填充 | fillna(method='ffill') | 时间序列、顺序数据 | 需保证索引有序 |
| 插值 | interpolate() | 时间序列、曲线型数据 | 高阶插值易过冲 |
| 分组统计填充 | groupby().transform() | 数据有天然分组结构 | 分组键不能有缺失 |
| 模型预测填充 | KNNImputer/IterativeImputer | 机器学习建模前 | 计算量大,注意泄漏 |
这张表是我自己常用的速查清单。下面就把每个方法掰开揉碎,用代码演示一遍。
2. pandas填充核心操作:从识别到落地的全套姿势
2.1 识别与统计:isnull、isna、notnull 怎么用
isnull()和isna()功能完全一样,isna是更现代的写法,notnull()是取反。它们返回的都是布尔型 DataFrame,用sum()可以统计每列缺失数量,用mean()可以直接得到缺失比例:
import pandas as pd import numpy as np df = pd.read_excel('orders.xlsx') # 每列缺失数量 print(df.isna().sum()) # 每列缺失比例 print(df.isna().mean()) # 等价写法 print(df.isnull().sum()) # 找出缺失比例超过30%的列,方便决定是否删除 high_missing_cols = df.columns[df.isna().mean() > 0.3] print(f'缺失超过30%的列: {list(high_missing_cols)}')一个小技巧:df.isna().any(axis=1)可以找出一行内是否有任何缺失;配合df[df.isna().any(axis=1)]就能把"有问题的行"整体拎出来看。想定位到具体哪些行列位置缺失,用np.argwhere(df.isna().to_numpy())。
2.2 二话不说先删除?dropna 的适用边界
dropna()是最偷懒但经常最昂贵的做法。我见过有人把一份5万行的表直接删到2万行,就是因为"懒得想怎么填"。删除的适用边界其实很窄:
# 删除任何含缺失值的行 df_clean = df.dropna() # 只有当某行所有核心字段都完整才保留 df_clean = df.dropna(subset=['order_id', 'customer_id', 'amount']) # 删除全都为空的行 df_clean = df.dropna(how='all') # 删除缺失超过3个的行 df_clean = df.dropna(thresh=len(df.columns) - 3)用subset指定关键列才是正确姿势。真正需要dropna的场景通常是:缺失行占比例极低(比如<1%)、缺失列本身是一次性采集的辅助字段、或者后续算法不支持缺失必须清洗干净。如果缺失比例达到5%~10%以上,删行前多想想。
2.3 fillna 的几种填法:常数、统计量、前后向填充
fillna()是填充的主力函数。最简单的用法是常数填充:
# 数值列填0 df['sales'] = df['sales'].fillna(0) # 字符串列填'未知' df['category'] = df['category'].fillna('未知') # 也可以对每列填不同的值 df = df.fillna({ 'sales': 0, 'age': df['age'].median(), 'category': '未知' })用均值/中位数/众数填充时,我强烈建议先算好统计量再填,避免代码里到处是df['age'].median()这种重复计算:
# 更推荐的做法:先存字典,再一次性填充 fill_values = { 'age': df['age'].median(), 'income': df['income'].median(), # 有离群点用中位数 'gender': df['gender'].mode()[0], # 分类列用众数 'sales': 0 # 业务含义是"当天没卖出" } df_filled = df.fillna(fill_values)这里有个老生常谈但必须提醒的:fillna默认返回新对象,不修改原 DataFrame。很多人忘记赋值回df,结果后面用df发现还全是NaN。要么显式赋值df = df.fillna(...),要么加inplace=True,但inplace在新版 pandas 里已经标记为不推荐,我习惯显式赋值。
前后向填充主要用在顺序数据上:
# 时间序列:用上一个有效值填充 df['close'] = df['close'].ffill() # 用下一个有效值填充 df['close'] = df['close'].bfill() # 上下都取平均(需要先ffill再bfill) df['close'] = df['close'].ffill().bfill()在老版本里很多教程教你写fillna(method='ffill'),在 pandas 2.0+ 里method参数已被移除,直接用ffill()和bfill()方法更干净。
2.4 插值进阶:interpolate() 真的能"猜"出好值吗
interpolate()是前向/后向填充的加强版,它不搬邻居的值,而是按趋势"拟合"出一个值。对于时间序列这种前后有连续关系的场景,效果比ffill好得多:
# 数据可视化曲线(比如每天的气温) df['temperature'] = df['temperature'].interpolate() # 如果索引是日期,最好先排序再插值 df_sorted = df.sort_values('date') df_sorted['temperature'] = df_sorted['temperature'].interpolate(method='linear')method参数有几个值得记的选项:
| 选项 | 行为 | 适用场景 |
|---|---|---|
linear | 两点连线,默认 | 大多数连续变量 |
time | 按时间间隔加权 | 日期索引且间隔不等 |
polynomial | 多项式拟合 | 曲线较平滑时,注意阶数别太高 |
spline | 样条插值 | 平滑程度高,但易振荡 |
nearest | 最近邻,等价于填最近值 | 分类取值有限时 |
插值不是万能的。我踩过的坑是:用method='polynomial'时阶数设成5,结果在边界处直接"过冲",插出一个物理上不可能的温度值。所以插值之后一定要用describe()或画图看分布范围,别跟实际业务常识冲突。
3. 一个完整案例,走通填充缺失值的全流程
3.1 造一份带缺失数据的业务表
光讲理论没感觉,我们造一份零售门店的销售数据来实操。这份表有日期、销售额、客流量、天气温度、门店类别5列,其中有一部分故意被改成NaN:
import pandas as pd import numpy as np np.random.seed(42) n = 200 df = pd.DataFrame({ 'date': pd.date_range('2024-01-01', periods=n, freq='D'), 'store_type': np.random.choice(['商场店', '社区店', '街边店'], size=n), 'sales': np.random.normal(3000, 800, size=n).round(0), 'traffic': np.random.poisson(lam=200, size=n), 'temperature': np.random.normal(22, 8, size=n).round(1) }) # 人为制造缺失:随机抽位置置为NaN np.random.seed(7) for col in ['sales', 'traffic', 'temperature', 'store_type']: idx = np.random.choice(df.index, size=int(n * 0.08), replace=False) df.loc[idx, col] = np.nan print(df.head(10))这个 DataFrame 里不同列缺失比例都在8%左右,比较接近真实业务数据的"脏度"。
3.2 缺失值体检:比例、分布与可视化
先用info()和isna()看整体:
print(df.info()) print(df.isna().mean().round(2))再看具体模式。我习惯画一个缺失热力图:用seaborn.heatmap能直观看出缺失是不是集中在某几行或某段时间:
import matplotlib.pyplot as plt import seaborn as sns plt.figure(figsize=(10, 4)) sns.heatmap(df.isna(), cbar=False, cmap='plasma', yticklabels=False) plt.title('缺失值位置分布图') plt.show()如果看到横向"条带",说明某些行集中缺失,可能是采集设备某天坏了;如果看到纵向"条纹",说明某列整段缺失,可能是字段整体没采集到。不同模式决定策略:整段缺失就别想用插值补,直接考虑删列或构造新特征。
3.3 按列类型分批填充:数值列与分类列分开处理
体检完就可以动手了。核心原则是逐列决策,不是一份字典通吃:
# 日期列:不可能插值,如果缺失就删除这些极端情况 df = df.dropna(subset=['date']) # 数值列:先看分布,决定用中位数还是插值 # sales受门店类型影响,先不急着整体填 print(df.groupby('store_type')['sales'].describe()) # temperature是连续型,且和日期趋势有关,用插值 df['temperature'] = df['temperature'].interpolate(method='time', limit_direction='both') # traffic是计数型,有离群点,用中位数 df['traffic'] = df['traffic'].fillna(df['traffic'].median()) # 分类列store_type:用众数 df['store_type'] = df['store_type'].fillna(df['store_type'].mode()[0])这里temperature我故意用interpolate(method='time'),因为它是带日期的连续变量,线性插值比填均值更能保留趋势。sales留到最后处理,因为它跟store_type明显相关,全局填一个数不合理。
3.4 分组填充与条件填充:groupby + transform 的精髓
不同门店的销售水平差异很大。商场店月销可能是街边店的3倍,用全局中位数填充,会把商场店的缺失值拉低、把街边店的缺失值拉高。正确做法是分组填充:
# 按门店类型填各自的中位数 df['sales'] = df['sales'].fillna( df.groupby('store_type')['sales'].transform('median') )transform('median')会返回一个和原 DataFrame 等长、且按组填充了中位数的 Series,直接作为fillna的填充值。这个写法比我早期用的groupby().apply(lambda x: x.fillna(x.median()))快得多,而且语义清楚。
还有一个冷门但实用的操作:多列联合条件填充。比如规定"周末的缺失客流用上周同日客流填充",逻辑上也能写成向量化操作:
# 给 data 加一个星期几字段 df['weekday'] = df['date'].dt.weekday # 用之前同星期的客流均值填充缺失值 df['traffic'] = df['traffic'].fillna( df.groupby('weekday')['traffic'].transform('median') )这两招用熟了,你会发现自己写不出for循环暴力填的代码了——分组填充不仅更优雅,而且性能高一个数量级。
3.5 填充效果怎么验证:多方案对比才算数
填完不是结束,必须验证。我至少会做三件事:
- 确认没有残留缺失:
df.isna().sum().sum()应为0。 - 看描述性统计:填充前后的
mean、std、min、max不能变化太大。比如 temperature 用插值,均值几乎不变;但如果 fillna(0) 填销售额,均值会被明显拉低,这本身就是信号。 - 可视化对比:分别画填充前后的分布直方图或核密度图,看是否有人为的"尖峰"。
before_mean = df['sales'].mean() # 需要先用原始数据计算 # 填充后再算 after_mean = df['sales'].mean() print(f'填充前均值: {before_mean:.2f}, 填充后均值: {after_mean:.2f}')还可以做个"占位测试":把真实值藏一部分当缺失,用各种策略填充,再用 RMSE 比较谁最接近原值。这一步在建模前的数据清洗中尤其值得做。
4. 进阶:用 scikit-learn 把填充做到"半自动化"
4.1 SimpleImputer:一行代码换策略
pandas 的fillna是手动挡,scikit-learn里的SimpleImputer就是自动挡。它可以在一个fit_transform里处理所有需要填充的列,并且在后续的机器学习流程中无缝嵌入Pipeline:
from sklearn.impute import SimpleImputer # 中位数填充数值列 imputer = SimpleImputer(strategy='median') df[['sales', 'traffic']] = imputer.fit_transform(df[['sales', 'traffic']])strategy支持的选项包括mean、median、most_frequent(众数)、constant(常数,配fill_value使用)。用SimpleImputer还有一个好处:它会自动保留列名(在新版本里fit_transform返回 DataFrame),避免重新拼列的麻烦。
4.2 KNNImputer:用"邻居"的力量填充
KNN 填充的思路很直观:找跟当前样本最相似的K个样本,用它们该字段的平均值来填。它天然能利用多个维度的信息,比单列填均值"聪明"不少:
from sklearn.impute import KNNImputer # 注意:KNNImputer只能处理数值列 df_num = df[['sales', 'traffic', 'temperature']] imputer = KNNImputer(n_neighbors=5, weights='distance') df_filled = imputer.fit_transform(df_num) df[['sales', 'traffic', 'temperature']] = df_filled用 KNNImputer 要注意三件事:
- 所有参与计算的特征必须都是数值型。分类列要么转
LabelEncoder,要么单独处理。 - 特征要标准化。否则距离会被量纲大的列主导,比如销售额几千、客流量几百,客流量指标基本不起作用。
n_neighbors别设置太大,否则会过度平滑,相当于把局部信息稀释成全局均值。
4.3 IterativeImputer:多重插补思路的工程落地
说到"高级",IterativeImputer是目前 sklearn 里最接近统计学多重插补逻辑的实现。它的思路是:把每一列当作目标变量,用其他列做模型预测缺失值,然后反复迭代直到收敛。简单说就是"用数据本身预测数据"。
from sklearn.experimental import enable_iterative_imputer from sklearn.impute import IterativeImputer imputer = IterativeImputer( max_iter=10, # 迭代轮数 random_state=42, # 保证可复现 initial_strategy='median' ) df_filled = imputer.fit_transform(df[['sales', 'traffic', 'temperature']]) df[['sales', 'traffic', 'temperature']] = df_filled它在数据量小、列之间有较强相关性的场景下效果最好。缺点也很明显:迭代多次,大数据集上慢得想骂人;而且如果某列缺失比例过高,模型学不到什么信息,填出来的值跟猜差不多。所以它适合在建模前的特征工程阶段使用,不适合报表统计场景。
需要特别留意的是,IterativeImputer目前还在 experimental 阶段,使用前需要显式导入enable_iterative_imputer。这也是很多新手报ImportError的原因。
4.4 missingno:一眼看穿缺失模式
处理缺失,少不了一款可视化利器:missingno。这个库专门用来分析缺失值分布,几行代码就能出图:
import missingno as mso # 缺失矩阵:横向是行,纵向是列,黑色块代表缺失 mso.matrix(df) # 缺失相关性热力图:哪些列的缺失经常一起出现 mso.heatmap(df)matrix图能快速识别出"某几行集中缺失"的情况,heatmap能看出不同列的缺失是否相关。比如sales和traffic的缺失总是同时出现,那很可能这两个字段是从同一个源系统同步失败的,修复数据源比填充更有意义。用pip install missingno就能装。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
fillna之后数据没变化 | 原列没赋值回去 / 缺失值是空字符串''而非NaN | df = df.fillna(...);先用df.replace('', np.nan, inplace=True) |
groupby().transform()报错 | transform返回的元素个数跟组内不一致 | 确认 lambda 里返回的是标量或 Series,用x.fillna(x.median())而不是x.median() |
| 插值后出现超大值 | 高阶插值过冲 | 降低阶数,改用linear或spline加order=1 |
填充后数值列变成float | 整数列里有NaN,pandas 自动升级类型 | 填充完用astype(int)显式转回 |
SimpleImputer报错说遇到字符串 | 混入了非数值列 | 先只选数值列,分类列单独处理 |
df.interpolate()结果不对 | 索引乱序 | 先sort_values('date')再插值 |
5.2 三个我踩过的坑:inplace、索引、dtype
第一个坑是inplace=True。我好几次写了df.fillna(0, inplace=True)后以为完事了,结果后续操作用的还是旧df。因为某些 pandas 方法在链式调用时inplace不生效,或者压根没落回原对象。现在的建议很明确:统一用df = df.fillna(...)这种显式赋值,可读性更好,也避免玄学问题。
第二个坑是索引错位。fillna接收的 Series 或 dict,如果索引跟原 DataFrame 对不上,会填进去一堆NaN。典型场景是:先把缺失的行筛出来算均值,再拿这个均值去填原表,稍不注意索引就对不上。更保险的做法是用df['col'].isna()做布尔掩码,配合df.loc[mask, 'col'] = value,全程不依赖索引顺序。
第三个坑是 dtype 被偷偷改成float。整数列里有NaN,pandas 会把它自动转成float64,因为NaN本身就是浮点类型。填充完以后如果想恢复整数,要自己astype(int)。但注意如果缺失值不是全部填完,astype(int)会直接报错,所以要先确保该列没有任何NaN了。
5.3 性能与工程化建议:别让 fillna 拉垮你的流程
最后聊聊性能。几万行的数据用什么方法都秒完,但到了几百万行或者做实时特征计算时,fillna的写法差距就出来了。
第一,用向量化操作代替循环。我见过有人这样写:
# 反例:千万别这么写 for idx in df[df['sales'].isna()].index: val = df.loc[idx, 'store_type'] df.loc[idx, 'sales'] = df[df['store_type'] == val]['sales'].median()这种写法逻辑没错,但循环里做了全表条件筛选,复杂度是 O(n²),500万行能跑半小时。换成groupby().transform()是 O(n),秒级完成。
第二,用 dict 或 Series 做批量填充。对每一列单独调用一次fillna会反复扫描 DataFrame,合并成一次df.fillna(fill_values)只扫描一次,性能更好,代码也更紧凑。
第三,把填充写进 sklearn Pipeline。如果填充是建模流程的一环,强烈建议放在Pipeline里:
from sklearn.pipeline import Pipeline from sklearn.impute import SimpleImputer from sklearn.ensemble import RandomForestRegressor pipe = Pipeline([ ('imputer', SimpleImputer(strategy='median')), ('model', RandomForestRegressor(n_estimators=100)) ]) pipe.fit(X_train, y_train)这样在交叉验证时,每次训练都只会用训练集的统计量去填充验证集,避免数据泄漏。这一步是很多人容易忽略的:直接用全量数据的均值填缺失,再切训练验证集,会导致验证性能虚高,模型上线后表现打折。
填充缺失值这件事,说到底是"在信息不足时做合理决策"的练习。填0、填均值、插值、模型预测,没有银弹,只有对业务的理解加上对数据的反复试探。我自己现在的习惯是:先画缺失图,再写策略备注,最后留一个验证脚本随时复盘。这样一套流程走下来,填充过的数据就敢放心交给下游了。
最后再分享个小技巧:在填充之前,先把原数据存一份快照或者加一列is_missing标记,把"缺失"这个事实保留下来。有些机器学习任务里,"这个值本来缺失"本身就是重要信号,保留它比抹掉它更有价值。这就是为什么我在做特征工程时,经常把df['col'].isna().astype(int)作为一个额外特征塞进模型——效果往往比费尽心思选填充策略还明显。