ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

缺失值填充全攻略:从缺失机制到模型填充与验证

缺失值填充全攻略:从缺失机制到模型填充与验证

简介:在实际数据分析项目中,缺失值会直接影响统计结果与模型训练效果,因此掌握多种填充策略十分必要。这份PDF面向Python初学者与数据分析入门者,系统梳理了缺失值产生原因、类型及六类常用处理方法。内容从直接删除法讲起,依次介绍前/后填充、均值/众数/中位数填充、SimpleImputer工具、插值法以及KNN填充,并配有基于pandas和scikit-learn的可运行代码示例,便于对照练习;同时点明众数填充可能面临多众数或无数值等注意事项。针对KNN填充还额外给出fancyimpute在Windows下的安装排错经验,能帮助读者避开常见配置坑;资源为单个PDF文档,共1个文件,压缩包约450KB,体量轻巧,适合快速查阅与随时复习。目前已有7792人学习下载,是入门缺失值处理时值得收藏的实操型参考资料。

1. 缺失值填充:不是补个数,是把数据生成过程想清楚

做数据处理的人,八成都在缺失值上栽过跟头。最典型的一幕是:报表交上去,领导指着某列问“这里为什么是空的”,你随口答“我用均值补了”,然后对方一句“那为什么用均值不用中位数”就把你问住了。更隐蔽的翻车发生在下游——模型上线后效果平平,回头排查才发现,训练时用全量均值填充的字段,在线上推理时用的是另一个统计口径,特征分布直接错位。缺失值填充在数据处理流程里看起来是最不起眼的一步,但它的影响会顺着数据清洗、特征工程一路传导到建模结果里,属于典型的“一步错、步步错”。这篇笔记从缺失机制讲起,落到统计量填充、模型填充、时序插值三套实操方案,再把高频踩坑点一条条列出来,最后给一套验证填充效果的检查习惯。适合正在做数据清洗、数仓建设或者准备特征工程的从业者,尤其是被“补还是不补、用什么补”反复折磨的数据分析师和算法工程师。

2. 先分清缺失机制:MCAR、MAR 与 MNAR 决定了你敢不敢补

2.1 三种缺失机制:同一张表,不同缺失,处理方式完全不同

缺失值填充的第一步不是写代码,而是判断缺失是怎么产生的。统计学里把缺失机制分为三类,这个分类直接决定了你后续敢不敢用统计量去补、补完会不会引入偏差。

MCAR(完全随机缺失)指某个字段是否缺失与其他任何变量无关,比如传感器偶发断电导致的整行缺失,这种缺失是最“干净”的,用均值、中位数填充基本安全。MAR(随机缺失)指缺失概率依赖于其他已观测变量,比如用户问卷里“收入”字段缺失,往往和“年龄”或“职业”相关,年轻人更不爱填收入,这时如果你只看收入列本身,会误以为缺失是随机的,实际需要用年龄、职业分组后再填充。MNAR(非随机缺失)最棘手,字段缺失与否和字段自身的真实值有关,比如“负债金额”缺失,很可能是因为负债太高的人不愿意填,这时任何基于其他列的统计填充都无法还原真实分布,只能引入外部数据或做敏感性分析。

实际操作里,我会用一个小技巧快速区分MCAR和MAR:把数据按“是否缺失目标列”拆成两组,对比其他特征列的均值或分布。如果两组在其他列上差异显著,说明缺失不是完全随机的,至少是MAR,填充时要带上分组信息。这个检查在 pandas 里几行就能完成,但很多团队的数据处理流程里根本没有这一步,直接用全量均值填充,这就是第一个坑的来源。

2.2 缺失值的“真假”甄别:占位符、业务零值与字符串空值的区分

在动手填充前,还有一道工序容易被忽略——先确认NaN到底是什么。真实生产环境里的数据清洗对象,远不止np.nan一种形态。

常见的情况有四种:第一种是真正的NaN,pandas读进来就是float nan;第二种是业务占位符,比如-999、-1、9999,这类数字混在正常取值范围内,用isna()根本查不出来;第三种是业务零值,比如“本月无消费”记录为0,但有些系统会用空值表示“无消费”,这两者的语义完全不同,前者是明确的0,后者可能是未知;第四种是字符串空值,""、"NULL"、"null"、"None",在从CSV或数据库导出时经常出现。

我一般会在填充之前先跑一遍值域扫描,把每列的取值种类和频次打出来,人工确认有没有占位符混入。比如下面这段代码:

import pandas as pd import numpy as np # 把常见占位符统一替换为 NaN placeholder_values = [-999, -9999, -1, 9999, "NULL", "null", "None", ""] df = pd.read_csv("raw_data.csv") # 先记录原始行数,方便后续对比 original_rows = len(df) for col in df.columns: # 数值列和字符串列分别处理 if pd.api.types.is_numeric_dtype(df[col]): df[col] = df[col].replace(placeholder_values[:4], np.nan) else: df[col] = df[col].replace(placeholder_values[4:], np.nan) # 统计每列缺失率 missing_ratio = df.isna().mean().sort_values(ascending=False) print(missing_ratio[missing_ratio > 0])

这段代码里placeholder_values列表按数值和字符串分了两组,替换时按列类型分别处理。关键参数是replace方法,它支持传入列表批量替换,不需要一列一列写判断。missing_ratio用isna().mean()算出每列缺失率,按降序排列后只打印有缺失的列,这一步能快速锁定哪些字段需要进入填充流程。

注意:-1在很多业务表里是“未填写”的意思,但有些表里它是合法的负值。替换占位符前必须和业务方确认取值字典,不要想当然。

这套甄别做完,缺失值填充的输入才是干净的。否则你前面垫再精细的填充模型,都会被-999这种占位符带偏。在数据处理的完整链路里,这一步通常放在数据清洗的最前面,先于格式统一和去重。

3. 统计量填充实操:均值、中位数、众数与前后向填充的适用边界

3.1 用 pandas 实现均值、中位数、众数与固定值填充

缺失率和缺失机制判断完之后,下一步就是选填充方法。统计量填充是数据清洗里最常用的手段,pandas 的fillna方法配合mean()、median()、mode()就能覆盖大部分表格场景。

# 中位数填充:对异常值不敏感 df["income_median"] = df["income"].fillna(df["income"].median()) # 按分组填充:不同组用不同的统计量 df["income_grouped"] = df.groupby("city")["income"].transform( lambda x: x.fillna(x.median()) ) # 众数填充:适合类别型字段 df["category_filled"] = df["category"].fillna( df["category"].mode().iloc[0] )

第一行的fillna(df["income"].median())是最基础的写法,但有一层隐含逻辑容易被忽略:median()计算的是全量列的统计量,如果缺失值本身集中在某个子群体里,这个全局中位数并不代表那个群体的水平。第二行用groupby("city").transform()把填充粒度下放到城市维度,每个城市的缺失值用该城市自己的中位数补,这才是统计量填充里更稳妥的常规做法。第三行的mode().iloc[0]取了众数的第一个值,因为mode()返回的是一个Series,可能有多值,必须取第一个。

实际项目中,我倾向于优先用中位数而不是均值。原因很实际:均值受异常值影响大,而缺失值常常和异常值相伴出现。比如某个字段有10%的缺失,剩下的90%里又有几个极端大值,均值会被拉高,填充结果整体偏移;中位数对这类污染不敏感,稳定性好得多。

3.2 前后向填充:时序数据里的 ffill 与 bfill,以及 limit 参数

表格数据用统计量填充没问题,但时序数据、传感器数据或者数仓里按时间排序的流水数据,情况就不一样了。这类数据的缺失值往往有强时间相关性——某个传感器在凌晨三点断了十分钟,这十分钟的数值不应该用全天均值补,更合理的估计是接近断点前后的读数。pandas 的前后向填充就是为这个场景设计的。

# 前向填充:用上一个有效值补缺失 df["sensor_ffill"] = df["sensor_value"].fillna(method="ffill") # 带限制的填充:最多连续补 3 个缺失点,超过则保持 NaN df["sensor_limited"] = df["sensor_value"].fillna(method="ffill", limit=3) # 分组内的前向填充:每台设备独立填充,避免跨设备串值 df["sensor_by_device"] = df.groupby("device_id")["sensor_value"].transform( lambda x: x.fillna(method="ffill") )

ffill是“forward fill”的缩写,意思是用前一个非空值向后填充。limit=3这个参数很关键,它控制连续填充的最大长度,超过这个长度就保持NaN。我在处理传感器数据时一般会设limit,因为超过一定时间跨度的缺失,前向填充的假设已经不可靠,不如留空让下游处理。第三行代码里的groupby("device_id")是防止跨设备串值的关键——如果有多台设备的数据混在同一张表里,直接全表ffill会把上一台设备的最后读数填到下一台设备的开头,这种错位在数仓场景里很常见。

bfill是反向填充,用后一个有效值往回补,一般用于序列中间的缺失。时序数据里ffill和bfill可以组合使用,但原则是先用ffill、再用bfill处理头部缺失,顺序反了会出问题。

3.3 统计量填充的边界:何时该停手

统计量填充虽然简单,但不等于无脑用。它有一个隐含假设——缺失位置上的真实值应该围绕某个中心点波动,且这个中心点可以从观测数据里估计出来。遇到以下情况,统计量填充就不太成立了。

第一种情况是缺失率过高。一列缺失超过30%,任何统计量填充都是在给你自己制造虚假信心,因为补进去的值占整列的三分之一以上,列分布已经被填充值主导。第二种情况是字段是偏态分布,比如收入、点击量这类长尾数据,均值会被极值拉偏,这时要么用中位数,要么考虑下一种方法。第三种情况是字段之间存在强相关关系,比如“学历”和“收入”高度相关,收入缺失时如果只看收入列本身的统计量,等于浪费了学历这个信息量很大的特征。

说到这,数据处理里那条铁律又浮出来了:填充方法越简单,对缺失机制的要求越苛刻。MCAR场景下均值中位数都行;MAR场景下要分组填充;MNAR场景下统计量直接失效。判断好机制,再用统计量,这套顺序不能反。

4. 模型预测填充:KNN、迭代回归与随机森林在什么场景下值得用

4.1 KNN 填充:原理、参数与标准化前置要求

当字段之间存在相关性,且缺失率在10%到30%之间时,统计量填充就有些力不从心了。这时值得考虑用其他字段的信息来预测缺失值,sklearn 的KNNImputer是上手成本最低的一个。

KNN填充的思路很直白:对每条含缺失值的样本,在完整样本里找K个最近的邻居,用邻居在该字段上的均值或加权均值去补。这里的“近”指的是其他字段上的距离近,所以KNN填充的前提是其他字段必须完整或接近完整。

from sklearn.impute import KNNImputer from sklearn.preprocessing import StandardScaler import pandas as pd # 先标准化数值特征,消除量纲对距离计算的影响 feature_cols = ["age", "tenure", "spend_score", "region_code"] scaler = StandardScaler() df_scaled = df.copy() df_scaled[feature_cols] = scaler.fit_transform(df[feature_cols]) # KNN 填充:n_neighbors=5,用距离加权平均 imputer = KNNImputer(n_neighbors=5, weights="distance") df_filled = imputer.fit_transform(df_scaled[feature_cols]) df_filled = pd.DataFrame(df_filled, columns=feature_cols) # 还原原始量纲 df_filled[feature_cols] = scaler.inverse_transform(df_filled[feature_cols])

n_neighbors=5是最常见的起点。邻居数太小(1-2),填充结果基本等于复制最近邻的取值,方差被压缩;邻居数太大(20以上),又退化成近似均值填充。weights="distance"表示距离越近的邻居权重越大,比默认的"uniform"均匀权重在数据分布不均时表现更好。StandardScaler是必须的,因为KNN算的是欧氏距离,如果age的范围是0-100,spend_score的范围是0-10,距离会被age主导,spend_score的信息约等于被丢弃了。

需要注意的一个细节是拟合和填充用的是同一个方法——fit_transform,KNNImputer根据非缺失样本计算出K个邻居,然后补全。它没有独立的模型参数需要保存,但如果你在训练集上做了填充,测试集上要做完全一致的处理,最简单的方式是把imputer对象pickle保存,测试集上重新调用transform。

4.2 IterativeImputer:把每一列都当成被预测目标

KNN适合字段关系比较直接的场景,但如果数据里有多个列都存在缺失,或者字段关系是非线性的,迭代回归填充更合适。sklearn 的IterativeImputer(迭代填充器)是MICE(链式方程多重插补)的简化实现,思路是:先做初始填充,然后轮流把每一列当目标、其他列当特征做回归,把预测值回填,反复迭代直到收敛。

from sklearn.experimental import enable_iterative_imputer from sklearn.impute import IterativeImputer from sklearn.ensemble import RandomForestRegressor # 注意:IterativeImputer 在 sklearn 里属于 experimental 模块 # 需要先 import enable_iterative_imputer 解除实验特性限制 imputer = IterativeImputer( estimator=RandomForestRegressor(n_estimators=50, max_depth=8), max_iter=10, random_state=42 ) df_imputed = imputer.fit_transform(df[feature_cols]) df_imputed = pd.DataFrame(df_imputed, columns=feature_cols)

estimator参数控制每一轮迭代用的预测模型,这里选了RandomForestRegressor,它能捕捉非线性关系,但训练耗时也更长。如果数据量在几十万行以上,我会换成BayesianRidge(贝叶斯岭回归),速度快很多,效果在多数场景下差距不大。max_iter=10表示每列会被预测并回填10轮,这是个经验值,超过这个轮数填充值的变化幅度已经很小。random_state=42固定随机种子,保证结果可复现——这在数据处理里非常重要,否则同一个脚本每次跑出来不同的填充值,排查问题时连责任都分不清。

IterativeImputer的优势是它对字段间的复杂关系利用得更充分,代价是计算开销大、逻辑是黑匣子。在数据清洗环节,我一般只在对填充质量有严格要求时才用它。比如金融场景里,某个用户的收入缺失且和职业、地域、年龄都相关,KNN只考虑局部邻居,迭代回归能利用全局结构,效果更细。

4.3 模型填充的三个实操约束:样本量、缺失率与特征选择

模型填充不是万能的,它有明确的适用边界。根据我的实际落地经验,三条约束比较重要。

样本量要够。模型填充是用完整样本去预测缺失样本,如果完整样本只有几百行,回归模型的方差会很大,填充结果反而不如中位数稳。经验阈值是完整样本至少2000行以上,模型填充的优势才体现得出来。缺失率要在合理区间。目标列缺失率太高,比如超过40%,完整样本太少,模型学不到可靠规律。这时与其硬补,不如考虑把“是否缺失”本身作为一个特征传给下游建模——有时候缺失本身就是一种信息。特征要精选。不是所有列都适合当预测特征,那些本身缺失率很高、或者和业务逻辑无关的ID列,应该直接排除。把无关特征塞进去,只会把噪声学进填充值里,数据处理清洗的精度反而下降。

这三点在执行层面经常被忽略,尤其是第三点。很多人的习惯是把能用的列全塞进fit_transform,结果填充值里掺进了ID列的噪声,算出来的相关性全部失真。线下的坑也出在这里——特征选择没做干净,模型填充的增益直接被抵消。

5. 缺失值填充避坑:五条高频翻车记录

5.1 全量均值填充多峰分布:补出来的值落在两个簇中间

现象:某电商平台的客单价字段缺失约8%,直接用全量均值填充后,下游聚类模型的效果明显变差,簇间边界模糊。

原因:客单价在数据里呈双峰分布,低客单价集中在50-100元,高客单价集中在500-800元,均值落在两者之间、同时也是两簇间隔最稀疏的地方。补进去的值既不属于低客单群体,也不属于高客单群体,把原本清晰的簇结构搅乱了。

解决:先看分布再决定统计量。对多峰分布,用中位数也不够,更合理的做法是先用聚类或其他列把样本分段,段内再填充。或者直接改用模型填充,让相关特征决定缺失样本属于哪个簇。

5.2 分组填充时忘了处理空分组

现象:按城市分组填充收入时,某城市组内全部缺失或组内只有NaN,transform(lambda x: x.fillna(x.median()))算出来的中位数是NaN,整组填充结果全部变成NaN。

原因:groupby后如果某组的所有样本值都是NaN,median()返回NaN,fillna用NaN去填NaN,结果还是NaN。代码不报错,但数据悄悄全空了,这个坑比报错更难抓。

解决:分组填充前先检查每组非空计数,对空组单独兜底。常见做法是先用全局中位数给所有缺失值打底,再做组内填充,两层逻辑不会因为个别空组出问题。

5.3 训练时用了全量统计量,线上推理时数据泄漏

现象:模型上线后线下AUC比线上高出一大截,排查了很久发现填充字段的分布两头不一致。

原因:训练时用全量数据的均值填充缺失值,线上推理时系统对单个样本做实时填充,只能用历史窗口算均值,两个均值口径不同,特征分布直接错位。这就是典型的数据泄漏变体——填充环节泄漏了未来信息。

解决:填充统计量必须在训练集上计算,并且固化下来。训练时把算好的均值或中位数保存成字典或JSON,线上加载同一份统计量做填充。KNN和IterativeImputer同理,训练集上fit完直接序列化保存,线上只做transform,绝不重新拟合。

5.4 先填充再切分数据,导致验证集被训练集“污染”

现象:用模型评估填充效果时,交叉验证分数虚高,换个数据集就拉胯。

原因:数据处理流程里先对全量数据做了填充,再切训练集和验证集。填充器在计算统计量或拟合模型时看到了验证集的数据,验证集不再独立,评估结果自然失真。

解决:严格按“先切分、后填充”的顺序执行。在训练集上拟合填充器,验证集和测试集只调用transform。这一点对KNN和IterativeImputer尤其致命,因为它们拟合时依赖全局结构,一旦看到验证集,等于提前泄露了答案。

5.5 fillna 原地修改后没有备份,下游排查时找不到原始值

现象:数仓某张表的缺失值被填充后,业务方质疑某个字段的数值不对,但原始数据已经被覆盖,无法追溯。

原因:df.fillna(inplace=True)直接改了原表,没有保留原始状态。

解决:每次填充前显式保留一份原始列或建mask矩阵。处理流程里把填充前后的列都留着,df["income_original"]和df["income_filled"]双列并排,任何时候都能对比还原。这个习惯成本极低,但能省下大量排查时间。

6. 填充效果验证三件套:分布对比、回填误差与敏感性分析

填充做完了,不能直接说“完事”。一套严谨的数据处理流程,在填充之后一定要有验证环节。我长期在用的验证方法有三件套,每件都是一个具体动作。

第一件是分布对比。填充后的字段分布应该和原始非缺失值分布保持一致,不能出现填充值堆积在某个值附近的现象。画分布图可以直接看出来。KS检验是个更量化的方式,把一个字段的原始值和填充值各拉成一组,检验两组是否来自同一分布,p值大于0.05说明分布没有显著变化。但如果缺失率高,KS检验的敏感度会下降,这时以专家判断为准。

第二件是回填误差模拟。这个动作在做模型填充时特别有用——在完整数据上人为制造缺失,把值遮住,用选择的填充方法预测,再和真实值对比误差。这个脚本应该固化下来,每次切换填充策略时跑一遍:

import numpy as np import pandas as pd from sklearn.impute import KNNImputer # 人为制造 15% 的缺失,模拟填充误差 test_df = df_complete.copy() np.random.seed(42) mask = np.random.rand(len(test_df)) < 0.15 test_df.loc[mask, "income"] = np.nan # 对比 KNN 填充和均值填充的误差 knn_imp = KNNImputer(n_neighbors=5) mean_pred = test_df["income"].fillna(df_complete["income"].mean()) knn_pred = knn_imp.fit_transform(test_df[["income", "age", "tenure"]])[:, 0] # 计算 RMSE true_values = df_complete.loc[mask, "income"] mean_rmse = np.sqrt(((mean_pred[mask] - true_values) ** 2).mean()) knn_rmse = np.sqrt(((knn_pred[mask] - true_values) ** 2).mean()) print(f"均值填充 RMSE: {mean_rmse:.2f}") print(f"KNN 填充 RMSE: {knn_rmse:.2f}")

这段代码里的np.random.rand(len(test_df)) < 0.15是造缺失掩码的常用方式,15%是缺失模拟里一个比较常用的比例上限——再高的话,任何填充器都很难还原真实分布。true_values只取被遮住的部分,RMSE算在真正被模拟为缺失的样本上,这样得到的是反映真实损失的误差,而不是混合了填充值和原始值的“伪精度”。如果不同方法的RMSE差异不大,选择更简单的方法永远是更理性的决策。

第三件是敏感性分析。填充方法不同,下游结果会不会发生质变?我习惯的做法是跑两套流程:一套用中位数填充,一套用模型填充,分别跑下游统计或建模,对比两份输出。如果关键指标差异在5%以内,说明缺口对填充方法不敏感,好说;如果差异很大,说明这个字段的缺失对下游影响强,需要回到缺失机制判断,甚至考虑调整数据采集环节,从根本上减少缺失。

从那以后,我每次处理缺失值都强制走一遍这个流程:先鉴定缺失机制,再甄别真假缺失,然后选填充方法,最后三件套验证。这套流程跑下来,填充就不再是拍脑袋的“补个数”操作,而是整个数据处理链路里一个可解释、可追溯、可验证的环节了。希望这些经验能帮你在缺失值填充上少走些弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进