简介:一份面向Python数据分析初学者的缺失值处理专题PDF,系统梳理了数据缺失的原因、类型及对应处理策略,适用于数据清洗、特征工程等数据预处理场景。资源为单个PDF文件,大小约450KB,内容紧凑、按方法分节组织,便于随时查阅。目前已有7792人学习参考。文中以实战代码为主线,从缺失值统计、直接删除阈值设定,到前填充/后填充、均值/中位数/众数填充、SimpleImputer、插值法及KNN填充六大类方法,每类都配有pandas或scikit-learn示例,例如dropna、fillna、interpolate、SimpleImputer的使用方式,并特别指出了众数填充应用中可能存在的多重众数问题;针对KNN填充所需fancyimpute库在Windows环境下的安装报错,还给出了依赖包升级、DLL缺失等具体排查步骤。无论是入门Python数据分析,还是希望系统掌握缺失值填充手段的读者,都能从中获得清晰的操作路径和避坑经验。
1. 缺失值填充:数据处理流程里最容易被跳过的一步
做数据处理时,最容易翻车的地方往往不是模型,而是数据清洗环节里最不起眼的缺失值填充。曾见过一份近 40% 缺失率的关键特征被直接 dropna 掉,建模样本直接缩水三成,后面所有特征工程都变了形。缺失值填充看似只是“填个空”,但它直接影响统计口径、特征分布和模型泛化能力。这篇笔记会按“先判断缺失类型、再选填充策略、最后验证效果”的顺序,把常见缺失值填充方案的原理、代码、参数和踩坑点完整过一遍,适合正在处理业务表、特征工程或时序数据的从业者,让你能照着跑通一套自己的填充流程。
2. 先分清缺失类型再动手:随机缺失、非随机缺失与结构缺失的判断方法
2.1 三问判断缺失类型:为什么缺失、是否随机、是否与标签相关
动手填充之前,先问三句话:这列数据为什么会缺失?缺失是随机发生的,还是跟某些变量有关?缺失本身是否携带业务信号?这三问决定了后面用均值还是用模型插补。
缺失类型通常分三类:完全随机缺失(MCAR,Missing Completely At Random)、随机缺失(MAR,Missing At Random)、非随机缺失(MNAR,Missing Not At Random)。MCAR 指缺失与任何变量无关,比如录入时手滑漏掉一个单元格,这种缺失处理起来最省心,删行或简单填充影响都不大。MAR 指缺失与其他已观测变量相关,比如女性受访者更不愿意填年龄,但年龄本身不是导致缺失的原因。MNAR 最麻烦,指缺失与缺失值本身相关,典型例子是高收入人群拒绝填收入字段——收入越高的越空,这时直接填充会把“不填”这个业务信号一起抹掉。
| 类型 | 含义 | 判断特征 | 典型例子 | 填充风险 |
|---|---|---|---|---|
| MCAR | 完全随机缺失 | 缺失与任何变量无关 | 录入随机漏填 | 低 |
| MAR | 随机缺失(可解释) | 缺失与其他已观测变量相关 | 不同城市填写率不同 | 中,需按相关分组填充 |
| MNAR | 非随机缺失 | 缺失与缺失值本身相关 | 高收入人群不填收入 | 高,填充会掩盖业务信号 |
区分三类不能靠统计检验一步到位,常见做法是先看业务来源,再看缺失与已观测变量的相关性。我会先把每个字段的缺失率跑出来,再按某个相关维度分组比较缺失率,比如按城市、按用户等级分别统计缺失比例,如果差异明显,说明大概率不是 MCAR。
import pandas as pd df = pd.read_csv("raw_data.csv") miss_rate = df.isnull().mean().sort_values(ascending=False) print("缺失比例\n", miss_rate[miss_rate > 0]) # 按城市分组看 income 缺失率差异,判断是否与已观测变量相关 grp_miss = df.groupby("city")["income"].apply(lambda s: s.isnull().mean()) print("分城市缺失率\n", grp_miss)逻辑说明:isnull().mean()是数据处理脚本里最常用的缺失率写法,先算出每列缺失比例,返回 Series 按降序排列。groupby("city")["income"].apply(...)则在每个城市组内单独计算缺失比例,用于辅助判断缺失是否与“城市”这个已观测变量相关。参数说明:apply在分组对象上按组传入 Series,返回结果以城市为索引,可以直接对比哪个组缺失严重。
2.2 缺失率评估与删除阈值:什么时候删行比填充更稳妥
并不是所有缺失都要填。如果某列缺失率超过 70%,且业务上无法追溯补录,保留它填充反而会引入大量噪声。我的经验阈值是:缺失率低于 5% 且样本量充足的字段,直接删除该列也可接受;缺失率在 5%~30% 之间,优先填充;超过 50% 的字段先问业务能不能补,不能补就考虑删除或转成二值指示列;超过 80% 的字段基本可以放弃。这些数值不是硬性标准,高通量数据处理里字段多、样本大,删除阈值可以略微放宽;小样本建模则要保守一点,每一列都值得抢救。
有一个需要特别注意的场景:数据量本身很小,比如只有几百行,这时删行会导致样本更少,模型方差变大。相反,如果单行大部分字段都缺失,删掉这一行反而比逐列填充更好,因为逐列填充会制造一堆“纸面数据”,徒增噪声。
row_miss_rate = df.isnull().mean(axis=1) print("行缺失率分布\n", row_miss_rate.describe()) # 删除缺失率超过 80% 的行 df = df[row_miss_rate < 0.8].reset_index(drop=True)逻辑说明:先算每行的缺失比例,describe()看整体分布,然后保留缺失率低于 80% 的行。参数说明:axis=1表示按行方向计算,0.8是阈值,可以根据业务调整,比如时序数据里删除整行需要格外谨慎,后文会讲原因。
2.3 伪缺失检查:把“NA”“-999”“空字符串”统一成 NaN
表格里看起来空,实际不空的情况几乎每次都能遇到。字符串列里既有真正的 NaN,又有"NA"、"N/A"、"null"、"-999"这样的占位值;有些系统导出时还会把缺失写成空字符串。这些伪缺失如果不统一,isnull()统计出来的缺失率是假的,后面填充也就全错了。
我一般会在读取数据时就通过na_values参数把各种占位符统一成 NaN,而不是读取后再逐列 replace。na_values在 pandas 读取阶段生效,效率更高,而且能避免后续对数值列误替换。
df = pd.read_csv( "raw_data.csv", na_values=["", "NA", "N/A", "null", "NULL", "-999", "Unknown"], keep_default_na=True ) print(df.info())逻辑说明:na_values指定哪些值应被解析为 NaN;keep_default_na=True表示保留 pandas 默认的 NaN 识别规则,比如NaN、nan、None仍然有效。参数说明:如果文件里本来就大量使用空字符串表示缺失,可以在na_values里加入"",但要先确认该列的空字符串不是有效的业务含义,比如“备注”列的空字符串就可能是“无备注”的意思。
3. 单变量填充的落地写法:均值、中位数、众数与常数填充的适用边界
3.1 均值、中位数、众数填充的最小代码
单变量填充是最基础的方案,也是日常数据处理里用得最多的。它的核心思路是:用同一列里已观测样本的某个统计量去替换缺失值。代码写起来简单,但选均值还是中位数,直接影响分布形态。
均值填充的适用条件是分布接近对称、没有严重长尾;一旦数据里有极端值,均值会被拉偏,填进去的值反而不在合理范围内。中位数填充则对极端值不敏感,长尾分布下明显更稳,所以我在实际项目里默认给数值列用中位数,除非已经有把握确认数据近似正态分布。众数填充只用于类别列,把缺失值填成出现次数最多的类别。
num_cols = ["age", "income", "score"] df[num_cols] = df[num_cols].fillna(df[num_cols].median()) cat_cols = ["city", "channel"] for c in cat_cols: df[c] = df[c].fillna(df[c].mode()[0])逻辑说明:第一行对多个数值列批量填充,median()返回每个数值列的中位数 Series,fillna自动按列名对齐填充。循环部分对类别列逐列填充众数,mode()[0]取得众数结果的第一个值,这只在存在多个众数时才会取到首个。参数说明:mode()返回一个 Series,可能有多个众数,用[0]表示取排序后的第一个,如果你希望出现多众数时回退到“Unknown”,可以改成df[c].mode().iloc[0] if not df[c].mode().empty else "Unknown"。
3.2 按分组填充:组内均值更贴近业务实际
很多情况下,整列填充会忽略分组差异。比如房价按城市差异极悬殊,收入按行业差距明显,如果全表统一填一个中位数,填出来的值对高房价城市和低房价城市都不合理。常见做法是先找到缺失列最相关的分组变量,在组内计算统计量再填充。
df["income_filled"] = df.groupby("city")["income"].transform( lambda s: s.fillna(s.median()) )逻辑说明:groupby("city")["income"]按城市分成若干组,transform将每个组内填充结果映射回原行位置,行数保持不变,这是与apply最核心的区别。apply返回的结果可能改变形状或维度,不适合直接赋值回 DataFrame;transform则保证等长输出,可以直接生成新列。参数说明:如果城市组内样本太少,组内中位数波动大,可以设置一个最小样本量,比如组内少于 30 条记录时回退到全局中位数。
3.3 填充后要检查分布变化:均值填充会压缩方差
均值填充最大的副作用是压缩方差。填充后的值都堆在均值上,分布会在均值处隆起一个尖峰,标准差变小,峰度变高。如果只是做描述性统计,影响不大;但特征进入线性模型后,模型会把这个尖峰当成“默认值”信号,导致对缺失样本的预测系统性偏向均值方向。
填充完之后,我都会打印一份填充前后的描述统计对比,不看均值,重点看标准差和分位数。标准差明显变小、分位数间距缩窄,就说明填充策略过于集中了。
print("填充前\n", df[df["income"].isnull()]["income_filled"].describe()) print("填充后\n", df[df["income"].notnull()]["income_filled"].describe())逻辑说明:第一段输出的是原始有值样本的统计量,第二段输出的是缺失值被填充后这些位置的统计量。两者对比,如果填充位置的分布和原始分布差异很大,说明填充策略可能扭曲了数据。参数说明:这里只用 describe 初步判断,更严格的验证需要用第 6 章的分布检验方法。
4. 多变量填充的做法与参数:回归插补、KNN 插补、迭代插补与线性插值
4.1 线性插值与时间序列填充:先保序再补缺
单变量统计量填充没有利用“顺序”信息。对时序数据,比如传感器读数、CMIP6 气候模拟输出里的站点时间序列,缺失值往往成片出现,这时插值法更合适。最常见的做法是 pandas 的interpolate(),它按序列位置做线性插值,本质是用缺失点前后的观测值连一条直线,把中间值补出来。
df["value_filled"] = df["value"].interpolate( method="linear", limit_direction="forward", limit_area="inside" )逻辑说明:interpolate(method="linear")对缺失值前后做线性插值;limit_area="inside"表示只在有观测值的区间内部填充,不对序列开头或结尾的缺失值做外推,避免在头尾产生不可控的线性外推值;limit_direction="forward"控制填充方向。参数说明:method可换"quadratic"、"spline"等,数据平滑度较好时可以用,但高阶方法在缺失区域跨度大时更容易产生振荡;如果缺失是前向传递型的,比如上一时刻的值延续到下一时刻,ffill()更直接,但要小心它会把一段历史值原样带入。
4.2 迭代插补与随机森林插补:用其他特征预测缺失
单变量填充只用了当前列的分布信息,没有利用其他特征的关系。当你手里有十几个特征,彼此相关性又强时,多变量插补更合适。sklearn 里最常用的是IterativeImputer,它把每个含缺失的特征当成目标变量,用其他特征拟合回归模型来预测缺失值,然后多轮迭代不断修正。
from sklearn.experimental import enable_iterative_imputer from sklearn.impute import IterativeImputer from sklearn.ensemble import RandomForestRegressor imputer = IterativeImputer( estimator=RandomForestRegressor(n_estimators=100, random_state=42), max_iter=10, random_state=42, initial_strategy="median" ) df_filled = imputer.fit_transform(df[num_cols]) df[num_cols] = df_filled逻辑说明:IterativeImputer的每轮迭代中,每个含缺失的列都会作为目标变量,其余列作为特征来训练回归器并预测缺失值,然后用预测值更新该列;多轮迭代让各列的取值不断修正,直到收敛。enable_iterative_imputer这一行是必须的,这个功能在 sklearn 里仍是实验特性,不显式 import 会直接报错。参数说明:estimator决定预测器,通常用RandomForestRegressor,也可以换成HistGradientBoostingRegressor提升速度;max_iter=10是迭代轮数,特征少时 5 轮就够,特征多且非线性强时调大到 20;initial_strategy="median"表示首轮先用中位数给所有缺失打底,再进入迭代;random_state保证结果可复现,避免“这次填出来的数下次又不一样”的玄学问题。
使用上有个容易忽略的点:fit_transform返回的是 numpy 数组,直接赋值给带索引的 DataFrame 会丢列名,后面做特征解释时会不方便;建议先包一层pd.DataFrame(df_filled, columns=num_cols),但前提是输入列顺序固定。
4.3 KNN 插补与特征缩放的关系:先标准化,再填数,再还原
KNNImputer的思路更直观:对每个缺失样本,在数据集中找到特征最相近的 k 个样本,用它们的值来估计缺失值。它本质上假设“相似样本的缺失值也应该相似”,这在业务表里非常适用,比如按用户特征补年龄、补消费水平。但它用的是欧氏距离,如果列间量纲差异很大,比如一列是收入几百万,一列是年龄几十,年龄在距离计算里的影响会被收入完全压掉。
from sklearn.impute import KNNImputer from sklearn.preprocessing import StandardScaler sc = StandardScaler() df_scaled = sc.fit_transform(df[num_cols]) imputer = KNNImputer(n_neighbors=5, weights="distance") df_filled_scaled = imputer.fit_transform(df_scaled) df[num_cols] = sc.inverse_transform(df_filled_scaled)逻辑说明:第一步先对数值列做标准化,让每列都变成零均值、单位方差,再算 KNN 距离才是公平的;插补完成后要把结果还原回原始量纲,否则填充出的收入、年龄数值会停留在标准化尺度上。参数说明:n_neighbors=5是邻居数,小数据集可以设小一点,比如 3,避免邻居太少造成局部过拟合;weights="distance"表示按距离加权,距离近的邻居权重大,"uniform"则是等权平均,通常"distance"结果更平滑一点。
5. 缺失值填充避坑指南:特征泄漏、索引错位与分布失真的排查
5.1 特征泄漏:训练集填充时顺带用了验证集信息
现象:模型在验证集上评估指标特别漂亮,上线后效果却明显缩水。
原因:填充统计量在训练和验证尚未拆分时就计算了,比如用全部数据的均值填充训练集,验证集的缺失值也引用了同一个统计量。这个统计量本身是从验证集数据里“看”出来的,放进模型后相当于变相让模型偷看了验证集分布,评估结果自然虚高。
解决:先切分数据,再在训练集上计算统计量,比如train_median = train["income"].median(),然后用这个标量分别填充训练集和验证集。验证集只负责 transform,不参与任何统计量的计算。
train, val = train_test_split(df, test_size=0.2, random_state=42) col_median = train["income"].median() train["income"] = train["income"].fillna(col_median) val["income"] = val["income"].fillna(col_median)这段代码里最关键的是col_median只来自训练集,验证集填充用的是训练集算出的同一个值。
5.2 索引错位:填充之后行对不上
现象:填充完生成新列,merge或pd.concat之后发现数据错行,某些填充值贴在了错误的记录上。
原因:填充本身不会改动索引,但如果你在过程中做过df.dropna()、sort_values()或分组筛选,索引顺序会乱;后面直接pd.concat([df, df_filled], axis=1)时 pandas 会按索引位置对齐,而非按行顺序。
解决:任何可能改动行的操作后立刻reset_index(drop=True);如果要在原始 df 和填充结果之间拼接,先把索引重置到连续编号再 concat。数据量大的场景可以给每条记录加一个稳定 ID,最后按 ID 合并,而不是依赖顺序。
5.3 分布失真:均值填充把分布挤成一个尖峰
现象:某列填充后画直方图,中间出现一根极高的柱子,两侧形状跟业务预期的自然分布完全不符。
原因:均值填充把所有缺失样本压到同一个点上,大量样本集中在填充值附近,方差被系统性压缩。这个问题在长尾分布和存在极端值的字段上尤其明显。
解决:换用中位数填充可以减少极端值影响;如果业务上缺失本身有意义,增加一个缺失指示列is_missing,让模型自己学习“缺失 vs 非缺失”的差异性,比强行把缺失值填成某个数更稳妥。
5.4 类别特征填充成众数后,缺失信号被吞掉
现象:把类别特征缺失值全部填成出现次数最多的类别后,模型权重里这个类别的影响异常放大,其他类别的样本预测被带偏。
原因:众数填充假设缺失样本最有可能属于主流类别,但实际业务中“没填”可能是刻意为之,比如用户不愿透露渠道来源,这个“不愿透露”本身就是信号。
解决:把缺失当作一个新的类别,比如把"city"列的 NaN 直接替换成"missing",或者对类别列做独热编码时保留 NaN 位置生成一列缺失指示,让模型有机会学到“缺失”和“某个具体类别”的区别。
5.5 测试集也参与了插补拟合
现象:IterativeImputer或KNNImputer在测试集上直接调fit_transform,模型在测试集上表现异常好,换一批真实线上数据立刻退化。
原因:插补器在测试集上重新 fit,等于把测试集特征分布的信息带进了填充值里;这些填充值又被送进模型做预测,实际上形成了一个闭环泄漏。
解决:对所有多变量插补器,只在训练集上fit,然后用同一个对象对训练集和测试集分别transform。这样做除了防止泄漏,也保证线上推理时能用同一套填补助手处理新数据,否则线上环境里根本没法对一个样本重新拟合插补器。
6. 填充后怎么验证:分布对比、业务抽查与多版本对照
6.1 用 KS 检验对比填充值与观测值分布
填充做得好不好,不是看有没有报错,而是看填充值和真实观测值是否来自同一分布。最简洁的工具是scipy.stats.ks_2samp,它对两列数据的经验分布做对比,p 值显著小于 0.05 时说明两者分布差异较大,填充策略需要重新考虑。
from scipy.stats import ks_2samp obs = df.loc[df["income"].notnull(), "income"] filled = df.loc[df["income"].isnull(), "income_filled"] stat, p_value = ks_2samp(obs, filled) print(f"KS 统计量: {stat:.4f}, p 值: {p_value:.4f}")逻辑说明:obs取原本有值的样本,filled取原本缺失、被填充后的样本,两者做双样本 KS 检验。p 值大于 0.05 时通常认为没有显著差异,但样本量小时检验功效有限,不显著不代表完全一致。参数说明:如果希望更严格,可以对比填充前后的整体分布(含观测值和填充值)与纯观测分布,观察整体分布是否被“带偏”。
6.2 业务侧抽查:拿已知规律验证填充值
统计检验只能证明“分布相似”,不能证明“值合理”。我习惯在填充后做一次业务抽查:挑几个已知规律的字段,人工核对填充值是否落在合理区间。比如某小区的房价中位数是 5 万,如果填充结果出现 5000 或 50 万,不管检验统计量多好看都要回头检查。
6.3 多版本对照与回滚
处理缺失值没有统一标准答案,最好的做法是把每种填充策略当做一个版本,固定模型超参和交叉验证划分,只切换填充策略对比效果。线上表现不符合预期时,可以直接回滚到上一版数据,这一步要求每个字段都记录填充方式和对应参数,后续排查时才知道“这个字段为什么全是同一个值”。
我自己的教训是在一次涉及 30% 缺失率的特征工程里,直接用全量数据均值做了填充,验证集效果好得异常,上线后实际效果却大面积走样。后来排查发现正是 5.1 的特征泄漏问题,而且缺失本身带着很强的业务信号,被一次均值填充全部抹掉了。自那以后,我每次都会记录各字段缺失率、填充分法和参数,并在建模前跑一次分布对比。不同业务场景对缺失值的容忍度差异很大,填充前花时间弄清数据来源,比急着调参更值得。希望帮到你。
本文还有配套的精品资源,点击获取