做特征工程这几年,我最大的感受就是:数据里的缺失值,从来不是“少几个数”那么简单,而是整个建模流程里最容易埋雷、也最容易被轻视的一环。 很多朋友拿到数据第一步就是 dropna() 一把梭,结果模型跑完才发现泛化能力一塌糊涂;也有朋友用了最传统的均值填充,结果特征分布被硬生生“压扁”,模型学不到真实规律。这些问题我在《Python 应用机器学习:代码实战指南》的笔记11里专门梳理过,今天就把这套特征缺失值插补的全攻略展开聊透,从缺失机制判断、常用插补方法对比,到完整可跑的 Python 代码和避坑经验,一次性讲明白。
这篇文章适合谁?如果你是刚接触机器学习、正在被各种“脏数据”折磨的初学者,或者已经用 sklearn 跑通几个模型但发现特征工程始终差口气的从业者,都可以从里面找到直接能用的思路和代码。核心就解决一件事:当数据出现缺失,你该怎么科学决策,而不是凭感觉处理。
1. 内容整体设计与思路拆解
1.1 为什么缺失值处理能决定模型“生死”
很多人会问,缺失值不就是删掉或者填个均值吗?能有多大影响?我直接用一次实际项目的对比来说话。之前做一个金融风控模型,原始特征有30多个,其中有5个特征缺失率在10%以上。一开始图省事,所有缺失直接 fillna(0),模型 AUC 只有0.71;后来换了迭代插补,AUC 提升到了0.79。这8个百分点的差距,在风控场景里可能就对应着几百万的坏账差异。
本质原因在于:缺失值本身可能携带信息。 比如用户填写借款申请表时,“月收入”这一栏空白,可能说明他没有固定收入,这本身就是个强信号。如果你直接填个平均值,就把这个信号抹掉了;填0,又会把分布拉得极其不均衡。所以缺失值处理不是“填空题”,而是“信息提取与重建”的过程。
另一个容易被忽略的问题是算法层面的假设。线性回归、逻辑回归、SVM 这类模型要求输入是完整的数值矩阵,缺失值直接会报错。树模型虽然能处理缺失,但不同实现方式(如 XGBoost 自动学习缺失方向)在不同缺失机制下表现差异很大。所以你需要先搞清楚数据为什么缺失,再决定用什么策略。
1.2 三类缺失机制:先判断“为什么缺”再谈“怎么补”
在统计学里,缺失值有三种机制,这个决定了你到底能不能用简单方法填充:
| 缺失机制 | 含义 | 例子 | 处理难度 |
|---|---|---|---|
| MCAR(完全随机缺失) | 缺失与任何变量无关 | 传感器偶发断电导致数据丢失 | 最简单,任何方法都可以 |
| MAR(随机缺失) | 缺失与其他已观测变量有关 | 收入越高越不愿意填写收入 | 中等,可用其他特征预测 |
| MNAR(非随机缺失) | 缺失与缺失值本身有关 | 收入低的人故意不填收入 | 最难,需要附加假设 |
我见过最多的就是 MAR 情况。比如电商订单数据里,“优惠券使用金额”字段经常缺失,通过分析发现是”未使用优惠券”的订单该字段为空,这就与订单本身特征强相关。此时如果用简单均值填充就会污染数据,更好的办法是把“是否缺失”也作为一个新特征,或者用一个分类模型来预测缺失值。
判断方法也很直接:把数据按“某列是否缺失”分成两组,对比其他特征的分布是否有显著差异(用 t 检验或 KS 检验)。如果差异明显,说明大概率不是 MCAR,你就得认真选择合适的插补策略。这一步很多人会跳过,但我建议一定要做,因为它直接决定后面的所有处理是否有效。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 缺失值探测:三行代码看清全貌
处理缺失值,第一步当然是把“敌情”摸清楚。我常用的就是 pandas 自带的探测方法,结合 missingno 这个可视化库,基本两分钟内能看清整个数据集的全貌。
python复制import pandas as pd
import numpy as np
import missingno as msno
import matplotlib.pyplot as plt
# 读取数据
df = pd.read_csv('customer_data.csv')
# 法1:数值化概览,看每列缺失个数和比例
missing_stats = pd.DataFrame({
'缺失个数': df.isnull().sum(),
'缺失比例': df.isnull().mean().round(4)
})
print(missing_stats[missing_stats['缺失个数'] > 0].sort_values('缺失比例', ascending=False))
# 法2:可视化矩阵,快速定位缺失模式
msno.matrix(df)
plt.show()
missingno 的矩阵图特别有用,它会把每个样本按行展示,白线就代表缺失位置。如果缺失呈现出明显的“带状”或“块状”,说明缺失不是随机分布的,背后有规律可循。比如下图如果有连续的几列同时出现大面积白带,可能就是某个采集批次整体出了问题,那直接考虑剔除该批次而不是逐列填充。
2.2 缺失值类型与“是否缺失”特征工程
在动手填充之前,还有一个非常关键但经常被忽略的操作:把“是否缺失”这个信息做成二值特征。
python复制# 对所有含缺失的列,生成对应的缺失标记特征
for col in df.columns[df.isnull().any()]:
df[f'{col}_is_missing'] = df[col].isnull().astype(int)
为什么要这样做?因为如前面所说,缺失本身可能就是信息。尤其在实际业务数据里,“用户是否填写了某项信息”往往与用户属性、行为偏好直接相关。这个额外特征给了模型一个选择:它可以自己学习到底要不要利用这个缺失信号,而不是被你的填充策略强行抹掉。
这里有个细节:对于 MNAR 机制,_is_missing 特征的作用甚至比填充值本身还大。比如“收入”字段缺失,你填充一个中位数,模型看到的是“中等收入”;但如果同时给它一个 income_is_missing=1 的信号,模型就可能学到“这个用户收入信息缺失,可能另有隐情”。因此我强烈建议,不管采用什么填充方法,先把这个标记特征加上,基本没有坏处。
还有一类特殊情况:缺失值可能不是 np.nan,而是用特殊值表示的。比如“收入”字段用 -1 表示“无收入”,用 999999 表示“拒绝透露”。如果不先做预处理把这些值转为 np.nan,后面所有统计插补都会出问题。我的习惯是用 df.replace({'income': {-1: np.nan, 999999: np.nan}}) 先把这些脏值统一替换掉,再走标准流程。
2.3 删除不能乱用:什么时候 dropna 是合理的
讲完探测和标记,就得聊聊删除策略了。dropna() 虽然简单,但用得不合适就会出大问题。我总结了三条经验:
可以放心删的情况是: 缺失比例确实很低(比如低于1%),而且样本量足够大,删掉后不会影响整体分布。比如100万条数据里有个别缺失,删掉几十条,无伤大雅。
一定要慎重删的情况是: 缺失集中在某一列且该列是重要特征。比如用户年龄字段缺失了30%,这个字段对预测来说很关键,直接删除意味着丢掉大量有效信息。
绝对不能删的情况是: 时间序列或面板数据的末端缺失。比如金融时间序列里最后几天的某个指标缺失,你如果直接把行删掉,会导致时间轴断裂,后续所有时序分析都做不了。
还有一种被很多人忽略的情况——整列缺失率超过50%,但该列在业务上又是核心。这时候删除不是好选择,我会优先看看能不能从其他特征推导出来,或者去源头补数据。实在不行再用高级插补。反正一句话:删除是最省事的方法,但通常也是损失信息最多的方法。
3. 实操过程与核心环节实现
3.1 基础插补法:均值、中位数、众数的适用边界
进入实操环节,先从最常用的统计量插补说起。pandas 里实现起来非常简洁:
python复制# 均值插补(适合数值型、近似正态分布)
df['age_filled_mean'] = df['age'].fillna(df['age'].mean())
# 中位数插补(适合数值型、有偏分布,推荐优先使用)
df['income_filled_median'] = df['income'].fillna(df['income'].median())
# 众数插补(适合分类型)
df['gender_filled_mode'] = df['gender'].fillna(df['gender'].mode()[0])
# 常数插补(适合有业务含义的场景)
df['income_filled_zero'] = df['income'].fillna(0)
关于选择均值还是中位数,我的经验是:先看分布,再看业务。 如果特征近似正态分布,均值和中位数差别不大,选均值;如果分布严重右偏(比如收入、房价这类),均值容易被极值带跑,中位数更稳健。我建议默认优先中位数,尤其是数据里有明显离群点的时候,均值插补会把整个分布往极端方向拉。
常数插补的 0 值填充最容易让人翻车。比如收入填0,模型会非常容易学到“收入为0的用户是个特殊群体”,但如果实际上0值是“用户没填”而真收入不一定是0,就引入了系统性偏误。所以常数插补最好确实有业务含义,比如“未消费金额填0”就合理,因为没消费确实就是0。否则别乱用。
3.2 时序与顺序数据的特殊插补:前后项填充与插值
对于时间序列数据,统计量插补往往不够用。比如股票价格、传感器读数这类数据,你用全局均值填充就会割裂时间趋势。正确姿势是使用前向填充(用上一个观测值填充)、后向填充(用下一个观测值填充)或线性插值。
python复制# 前向填充:用前一个有效值填充,适合缓慢变化的时序指标
df['sensor_ffill'] = df['sensor'].ffill()
# 后向填充:用后一个有效值填充
df['sensor_bfill'] = df['sensor'].bfill()
# 线性插值:按前后两个有效点做线性过渡,适合近似线性的变化
df['sensor_interp'] = df['sensor'].interpolate(method='linear')
# 时间加权插值:如果索引是时间戳,可以按时间间隔权重插值
df['sensor_time_interp'] = df['sensor'].interpolate(method='time')
这里有个坑要特别注意:ffill() 默认会沿着行索引方向填充,如果你的数据不是按时间排序的,一定要先 sort_values('timestamp') 再填充,否则结果完全错乱。
另外,插值方法的选择也有讲究。线性插值假设相邻两点之间的变化是匀速的,适合平滑信号;如果数据波动剧烈(比如股价分时数据),用 polynomial 或 spline 插值可能更贴近实际,但这些方法计算量更大,而且外推能力请设置为 limit_area='inside' 来避免在序列两端产生离谱的外推值。我实测过,method='time' 在时间间隔不均匀的情况下比 linear 要准不少,因为它是按真实时间间隔来分配权重的。
3.3 进阶方法:KNN 插补与迭代插补(IterativeImputer)
当你处理的是多特征、且特征之间有相关性的表格数据时,KNN 插补和迭代插补就是大杀器。我强烈推荐 sklearn 里这几个接口,因为它们封装良好,对新手很友好。
KNN 插补的核心思路是: 找到与含缺失样本最相似的K个样本(基于其他完整特征列计算距离),用这K个样本在该列上的平均值(或加权平均)作为填充值。
python复制from sklearn.impute import KNNImputer
# 建议先将分类特征做数值编码
imputer_knn = KNNImputer(n_neighbors=5, weights='distance')
df_encoded = df.copy()
# 假设 categorical_cols 是分类特征列表,先将它们标签编码
for col in categorical_cols:
df_encoded[col] = df_encoded[col].astype('category').cat.codes
df_knn_filled = imputer_knn.fit_transform(df_encoded)
df_knn_filled = pd.DataFrame(df_knn_filled, columns=df_encoded.columns)
n_neighbors 选多少?我的经验值是5~10。太小容易过拟合局部噪声,太大又会抹平局部特征。weights='distance' 意味着距离越近的样本权重越大,这一点在多数场景下比默认的 uniform 要好。
IterativeImputer(迭代插补)的原理更高级: 它可以简单理解为“用数据中其他特征来预测缺失特征”的循环过程。具体做法是:先把缺失特征用临时值填上(比如均值),然后按顺序对每个含缺失的特征训练一个回归模型(默认是 BayesianRidge),用它预测缺失位置,更新填充值,如此往复直到收敛。
python复制from sklearn.experimental import enable_iterative_imputer
from sklearn.impute import IterativeImputer
from sklearn.ensemble import RandomForestRegressor
# 使用默认的 BayesianRidge 作为估计器
imputer_ite = IterativeImputer(max_iter=10, random_state=42)
df_ite_filled = imputer_ite.fit_transform(df_encoded)
df_ite_filled = pd.DataFrame(df_ite_filled, columns=df_encoded.columns)
# 也可以换成随机森林,适合非线性关系较强的数据
imputer_ite_rf = IterativeImputer(
estimator=RandomForestRegressor(n_estimators=100, random_state=42),
max_iter=15,
random_state=42
)
df_ite_rf_filled = imputer_ite_rf.fit_transform(df_encoded)
据我实测,IterativeImputer 是目前 scikit-learn 内置方法里插补精度最高、对下游模型提升最显著的方案,但代价是计算开销大。如果你的数据集有几十万行、几百个特征,一定要做好计算资源的计划。另外,迭代插补假设特征之间是相关的,如果特征之间完全独立,效果也不会比均值插补好太多。
3.4 用模型预测缺失值:把插补变成监督学习
除了 sklearn 内置的插补器,还有一个思路值得掌握:把“含缺失列”当作目标变量,用其他完整特征训练一个预测模型。 这种方法特别适合某一列缺失率高,且与其他特征有较强相关性的场景。
python复制from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import train_test_split
# 以 age 列为例,假设它存在缺失
df_train = df[df['age'].notna()]
df_missing = df[df['age'].isna()]
X_train = df_train.drop(['age'], axis=1)
y_train = df_train['age']
X_missing = df_missing.drop(['age'], axis=1)
# 模型训练
model = RandomForestRegressor(n_estimators=200, random_state=42)
model.fit(X_train, y_train)
# 预测缺失值
predicted_age = model.predict(X_missing)
df.loc[df['age'].isna(), 'age'] = predicted_age
这个方法的优势在于:可以灵活加入业务特征,比如从注册时间推导出的用户“网龄”、从浏览记录聚合出的“活跃度”等,这些特征对年龄预测很有帮助。缺点是需要保证其他特征本身没有大量缺失,否则模型输入质量堪忧,容易陷入“鸡生蛋”的循环。所以实际操作中,我会先处理缺失率低的列,给预测模型准备好相对完整的特征集,再处理缺失率高的列。
3.5 各方法效果对比与选型总结
为了让大家看得更清楚,我把前面所有方法放在一起做了个对比表,总结各自的适用场景、优缺点和计算开销:
| 方法 | 适用场景 | 优点 | 缺点 | 计算开销 |
|---|---|---|---|---|
| 删除法 | 缺失极少、样本充足 | 简单公道、不引入偏差 | 信息损失、无法处理高缺失率 | 极低 |
| 均值/中位数插补 | 低缺失率、MCAR | 快速简单、易实现 | 方差低估、破坏特征相关性 | 极低 |
| 众数插补 | 分类特征 | 保持分布、简单 | 可能引入偏误、可替代性 | 极低 |
| 前向/后向填充 | 时间序列 | 保持时间趋势 | 不适用于交叉截面数据 | 低 |
| 线性插值 | 平滑时序数据 | 过渡自然 | 对剧烈波动数据表现差 | 低 |
| KNN 插补 | 特征相关、样本量大 | 考虑相似样本、效果较好 | 对高维数据计算量大、K值敏感 | 中 |
| IterativeImputer | 特征相关性强的多特征数据 | 插补精度高、利用全局信息 | 计算昂贵、可能过拟合 | 高 |
| 模型预测插补 | 缺失列与其他特征强相关 | 可解释性好、灵活 | 需要完整特征集、可能循环依赖 | 高 |
选型经验:优先看缺失机制和数据形态。 MCAR 且缺失率低,用中位数插补就足够;MAR 且特征相关性较强,优先上 IterativeImputer 或 KNN;MNAR 就要额外加 is_missing 特征,同时考虑用模型预测插补。一句话,不要盲目追求高级方法,先评估数据特征再选工具。
4. 常见问题与排查技巧实录
4.1 插补前后模型效果不升反降?
这是最让人头疼的问题。你明明用了最先进的插补方法,结果模型效果反而更差。我在实际项目中就踩过这个坑。排查思路有三个:
第一步,检查缺失率与信息量。 如果某一列缺失率超过90%,无论用什么插补方法,填充出来的值基本都是噪声。这时候我倾向于直接删除该列,只保留 is_missing 标记特征,反而更干净。
第二步,检查插补是否引入了数据泄漏。 这也是一个极易踩的坑:你在划分训练集和测试集之前就做了插补,比如用全量数据的均值或 KNN 插补。这样一来,测试集的信息就混进了训练过程,模型评估结果会偏乐观,上线后表现大幅缩水。正确做法是:先切分数据,再分别在训练集和测试集上做插补,并且测试集的填充参数要继承训练集。
python复制# 正确姿势:先切分再插补
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(
df.drop('target', axis=1), df['target'], test_size=0.2, random_state=42
)
# 在训练集上计算填充值
median_income = X_train['income'].median()
mean_age = X_train['age'].mean()
# 训练集和测试集都使用训练集算出的参数
X_train['income_filled'] = X_train['income'].fillna(median_income)
X_test['income_filled'] = X_test['income'].fillna(median_income)
第三步,检查模型对插补值的敏感度。 有些模型(如线性模型)对特征尺度特别敏感,插补值的微小波动可能影响权重差异。可以做个敏感性分析:把填充值上下浮动5%,观察模型指标的变化幅度。如果波动太大,说明模型对该特征过于依赖,可能需要更稳健的插补方法。
4.2 分类特征缺失怎么办?不能直接 fillna(mean)
分类特征的缺失处理,很多新手容易想当然地填入众数。这个方法不是不能用,但有一个大坑:众数类别占比过小时,填充众数反而曲解了数据。 比如“支付方式”这一列,90%是支付宝,如果缺失时填支付宝,其实是在强化已有的主导类别,但缺失的10%很可能是“未支付”或“其他”类别。
我的标准做法是:
python复制# 第一步:把缺失单独作为一个类别(最安全)
df['pay_method'] = df['pay_method'].fillna('unknown')
# 第二步:如果缺失率很低,可以直接填众数
df['pay_method'] = df['pay_method'].fillna(df['pay_method'].mode()[0])
# 第三步:用其他特征预测这个分类变量(缺失率较高时)
from sklearn.ensemble import RandomForestClassifier
df_train = df[df['pay_method'] != 'unknown']
X_train = df_train.drop('pay_method', axis=1)
y_train = df_train['pay_method']
model = RandomForestClassifier(n_estimators=100, random_state=42)
model.fit(X_train, y_train)
# 对缺失样本做预测填充
我个人最推荐“把缺失单独作为一个类别”的方案,因为它在多数业务场景下是最安全的,不会臆造数据,还保留了缺失信息。只有在缺失率极低(比如小于1%)且建模时不允许有未知类别出现的情况下,才把缺失合并到众数类别里。
4.3 插补后特征分布严重扭曲的检查与修复
插补之后,有一个关键但常被跳过的步骤:对比插补前后的特征分布。 如果分布扭曲太严重,说明插补方案可能有问题。具体操作是用 matplotlib 画直方图或KDE密度图对比。
python复制import seaborn as sns
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
sns.histplot(df_original['income'], kde=True, ax=axes[0]).set_title('Original')
sns.histplot(df_filled['income'], kde=True, ax=axes[1]).set_title('After Fill')
plt.show()
正常情况下,插补后的分布应该与原始分布大致相似,不应该出现“中间变得特别高、尾部被压扁”的情况。如果发现均值插补导致方差被严重低估,可以考虑给填充值加一点随机噪声,或者改用多重插补思路。scikit-learn 没有直接提供多重插补,但可以用 IterativeImputer 保存多个插补版本,取平均值作为最终结果,这种方法能部分缓解方差低估问题。
4.4 常见问题速查表
| 问题场景 | 典型表现 | 解决方案 |
|---|---|---|
| 数据缺失率超过50% | 特征信息量不足 | 优先考虑删除或保留 is_missing 标记 |
| 缺失值用 -1/999 等特殊值表示 | 统计结果异常偏大 | 先用 replace 转为 np.nan 再处理 |
| 时间序列末端缺失 | 时序分析报错或预测失效 | 用前向填充 + 时间插值,禁止直接删行 |
| 训练测试集划分后单独插补 | 线下指标好,线上崩溃 | 先切分,再在训练集上拟合填充参数 |
| 分类特征缺失占比高 | 众数填充导致类别失衡 | 把缺失单独作为一个类别或模型预测 |
| 插补后分布严重扭曲 | 直方图中间突出尾部平坦 | 改用 KNN/IterativeImputer 或加噪声 |
排查技巧上还有一点想补充:不要只看插补后的整体统计指标,一定要分样本段看。 比如按时间分组对比插补前后的特征均值,如果某段时间的异常高或低,很可能插补受到了某个异常样本的干扰。这个分层对比的习惯帮我在好几个项目里发现了隐藏的数据质量问题。
5. 实操过程中我坚持的几个习惯
关于缺失值插补,踩了这些年坑,有几个习惯想分享给正在读这篇文章的朋友。
第一,永远保留原始数据备份。 无论用了什么插补方案,不要在原始 DataFrame 上直接覆盖。我通常会用 df_original = df.copy() 存一份备份,后面所有处理都在副本上进行。这样如果插补效果不好,随时可以回退,不用重新读数据。
第二,插补代码一定写注释记录“为什么”。 比如“这里用中位数填充 income,因为分布右偏且离群点多”。几个月后你回到这个项目,看着一堆 fillna 命令很容易抓狂,但如果你写了当时的判断理由,就能快速理清思路。而且这点对团队协作也很重要,不然你的队友会在心里默默吐槽“这代码到底想干嘛”。
第三,对插补结果做记录审计。 我给每个特征都建了一个“缺失值处理日志”,记录缺失率、采用的插补方法、填充前后均值/方差变化、模型效果对比。这个日志虽然简单,但在项目上线后排查问题、写技术文档时价值巨大。很多时候模型出了问题,你回溯不到原因,就是因为没有记录这些处理过程。
第四,插补时留意业务含义,别把算法当万能。 比如你做一个信贷风控模型,借款人“年收入”缺失,用 KNN 插补用得再漂亮,也不如先去查一下是不是可以从其他申请材料里推导出来。算法只是工具,业务理解才是根本。
第五,如果条件允许,尽量建立多重插补版本。 我常在团队内部建议,对关键特征生成3个不同的插补版本,分别建模,看不同版本的模型结果是否稳定。如果结果波动很大,说明数据缺失本身是个强信号,你会倾向于在特征工程上多加入业务侧的信息来补充,而不是单纯依赖算法。
最后再分享一个小技巧:插补完成后,把填充后的值做一个“是否被填充”的标记特征再加回模型。 这个特征的成本极低,但往往能给模型带来意想不到的提升。我做过一个流失预测项目,加上这一列之后 AUC 提升了接近1.5个百分点。很多时候,数据缺失的原因比数据本身更有故事。
