ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

数据清洗缺失值处理:删除与填充策略的决策逻辑与实操指南

数据清洗缺失值处理:删除与填充策略的决策逻辑与实操指南

1. 缺失值处理的核心决策逻辑

1.1 为什么缺失值处理是数据清洗的分水岭

做过数据清洗的人都有一个共识:缺失值处理是整个流程里最考验判断力的环节。它不像去重那样有明确标准,也不像格式转换那样有固定规则。删还是填,怎么删,怎么填,每一步都直接影响后续建模和分析的结论。

我见过太多新手拿到一份带空值的数据集,第一反应就是df.dropna()一把梭。结果原本一万行的数据删完只剩三千行,后面跑出来的模型偏差大得离谱。也见过另一种极端,不管什么字段都拿均值填上,把分类变量用0填充,最后特征分布完全变形。

这个问题的本质在于:缺失值不是单纯的“数据缺陷”,它本身携带信息。一个用户没填年龄,可能是因为隐私意识强;一台设备没上报温度,可能是传感器故障;一份问卷某个题目大面积空白,可能是题目表述有歧义。这些“为什么缺失”的线索,往往比填一个数字更有价值。

所以缺失值处理的第一步从来不是打开代码,而是先搞清楚三件事:缺失的比例有多大、缺失是随机的还是有规律的、这个字段在后续分析中扮演什么角色。这三个问题想清楚了,删还是填的答案自然就出来了。

1.2 缺失机制的三种类型与判断方法

统计学上把缺失机制分为三类,理解这三类是做出正确决策的理论基础。

完全随机缺失(MCAR):缺失与任何变量都无关,纯粹是随机事件。比如问卷录入时随机漏敲了几个格子。这种情况下删除缺失样本不会引入偏差,只是损失一些信息量。

随机缺失(MAR):缺失与观测到的其他变量有关,但与缺失值本身无关。比如收入字段的缺失与教育水平有关(高学历人群更不愿意透露收入),但在同一教育水平内,缺失是随机的。

非随机缺失(MNAR):缺失与缺失值本身有关。比如高收入人群更倾向于不填收入,这时候缺失本身就意味着“这个值偏高”。

判断方法上,我通常用两个手段快速摸底。第一个是缺失矩阵可视化,用missingno库的matrix图能一眼看出缺失是散点状还是成片状。第二个是分组对比,把目标字段是否缺失作为分组变量,看其他字段的分布有没有显著差异。

import missingno as msno import matplotlib.pyplot as plt # 缺失矩阵图:散点随机分布说明可能是MCAR msno.matrix(df) plt.show() # 缺失相关性热力图:看缺失之间是否有关联 msno.heatmap(df) plt.show()

如果缺失矩阵图显示空白呈随机散点,大概率是MCAR;如果某些列的空白总是同时出现,说明缺失之间存在关联,需要进一步分析。

1.3 删除策略的适用边界与代价计算

删除法分两种:按行删除和按列删除。很多人只知道按行删,其实按列删在某些场景下更合理。

按行删除适用于:缺失比例低于5%、缺失机制为MCAR、样本量充足。代价是损失样本量,如果缺失集中在某些重要子群体中,会引入选择性偏差。

按列删除适用于:某字段缺失率超过70%且该字段不是核心分析变量、字段信息可由其他字段替代。代价是可能丢失关键特征。

这里有个经验公式可以参考:当某列缺失率超过60%时,除非这个字段是业务核心指标,否则优先考虑删除整列。当某行缺失字段数超过总字段数的50%时,这行数据的可靠性存疑,可以考虑删除。

但要注意一个陷阱:删除操作必须在训练集和测试集上分别进行,或者先划分再删除。如果先在全量数据上删除,再划分训练测试集,会导致数据泄露。正确做法是先train_test_split,然后在训练集上统计删除规则,再应用到测试集。

from sklearn.model_selection import train_test_split # 先划分 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) # 在训练集上确定删除阈值 threshold = 0.6 cols_to_drop = X_train.columns[X_train.isnull().mean() > threshold] # 应用到两个集合 X_train = X_train.drop(columns=cols_to_drop) X_test = X_test.drop(columns=cols_to_drop)

注意:删除操作是不可逆的,执行前务必先备份原始数据,或者用df.copy()创建副本再操作。

2. 填充策略的深度解析与实操选择

2.1 统计量填充的适用场景与隐藏陷阱

均值、中位数、众数填充是最常见的三种统计量填充方法。它们的适用场景有明确区分。

均值填充适用于近似正态分布且缺失率较低的数值字段。但均值填充有个致命问题:它会缩小数据的方差,让分布变得更集中。如果缺失率超过10%,均值填充后的分布会明显偏离原始分布。

中位数填充适用于偏态分布或存在极端值的数值字段。比如收入、房价这类右偏数据,中位数比均值更稳健。中位数填充对方差的影响比均值小,但同样会压缩分布。

众数填充适用于分类字段或离散数值字段。比如性别、城市等级这类字段,用出现频率最高的值填充是合理的。

这里有个实操技巧:填充前先记录填充标记。我习惯在填充时额外加一列xxx_is_missing的布尔标记,这样模型可以学习“缺失本身”是否具有预测力。

# 数值字段:中位数填充 + 缺失标记 for col in numeric_cols: df[f'{col}_is_missing'] = df[col].isnull().astype(int) df[col] = df[col].fillna(df[col].median()) # 分类字段:众数填充 + 缺失标记 for col in categorical_cols: df[f'{col}_is_missing'] = df[col].isnull().astype(int) df[col] = df[col].fillna(df[col].mode()[0])

提示:众数填充时如果存在多个众数,mode()[0]只取第一个,建议先检查df[col].mode()的结果再决定。

2.2 前向填充与后向填充的时间序列专用技巧

对于时间序列数据,统计量填充往往不合适,因为时间维度上的连续性比全局统计量更重要。这时候前向填充和后向填充是首选。

前向填充用上一个有效值填充当前缺失值,适用于状态持续型数据,比如设备状态、用户等级这类不会频繁变化的字段。后向填充用下一个有效值填充,适用于事后补录型数据。

但这两个方法有个共同问题:如果缺失段很长,填充值会与真实值偏差很大。我的做法是设置一个limit参数,限制连续填充的最大长度。

# 前向填充,最多连续填充3个 df['temperature'] = df['temperature'].fillna(method='ffill', limit=3) # 后向填充,最多连续填充2个 df['status'] = df['status'].fillna(method='bfill', limit=2)

对于时间序列,还有一个更精细的做法:线性插值。它假设缺失段内数值是线性变化的,比单纯的前向填充更平滑。

# 线性插值,适用于时间索引连续的数据 df['temperature'] = df['temperature'].interpolate(method='linear') # 时间索引插值,考虑时间间隔的权重 df['temperature'] = df['temperature'].interpolate(method='time')

注意:插值前必须确保数据按时间排序,否则插值结果完全错误。用df.sort_values('timestamp', inplace=True)先排序。

2.3 模型预测填充的进阶玩法

当缺失字段是重要特征且缺失率较高时,统计量填充和插值都不够用,这时候可以考虑用模型预测填充。核心思路是:把缺失字段作为目标变量,用其他完整字段作为特征,训练一个模型来预测缺失值。

常用的模型有K近邻(KNN)、随机森林、XGBoost等。KNN填充适合样本量不大、特征维度适中的场景;随机森林和XGBoost适合特征复杂、非线性关系强的场景。

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 ) df_filled = pd.DataFrame( imputer.fit_transform(df[numeric_cols]), columns=numeric_cols )

模型填充的优点是能捕捉特征间的复杂关系,填充值更接近真实分布。但缺点也很明显:计算成本高,且如果特征本身也有缺失,填充效果会打折扣。另外,模型填充容易过拟合,填充值可能过于“完美”,反而失去真实性。

我的经验是:模型填充适合缺失率在10%到50%之间的重要数值字段。缺失率太低没必要用模型,缺失率太高则模型训练样本不足,填充效果差。

2.4 分类字段填充的特殊处理方式

分类字段的填充比数值字段更棘手,因为不能简单用均值。除了众数填充,还有几种实用方法。

新增类别填充:把缺失本身作为一个新类别,比如“未知”“其他”。这种方法适用于缺失率较高且缺失可能具有业务含义的场景。比如用户职业字段缺失,可能意味着用户不愿意透露,这本身就是一种信息。

分组众数填充:先按某个相关字段分组,再在组内用众数填充。比如按城市等级分组,一线城市的缺失职业用一线城市的众数填充,比全局众数更合理。

# 分组众数填充 df['occupation'] = df.groupby('city_tier')['occupation'].transform( lambda x: x.fillna(x.mode()[0] if not x.mode().empty else '未知') )

基于规则的填充:根据业务逻辑制定填充规则。比如“年龄缺失且婚姻状况为未婚”的用户,职业字段填充为“学生”可能比众数更合理。

提示:分类字段填充后,务必检查类别分布是否发生显著变化。如果填充后某个类别的占比从5%飙升到30%,说明填充策略有问题。

3. 完整实操流程与代码实现

3.1 数据诊断与缺失模式分析

拿到一份带缺失值的数据,第一步不是急着处理,而是先做全面诊断。我通常按以下顺序操作。

先看整体缺失情况:

import pandas as pd import numpy as np # 加载数据 df = pd.read_csv('data.csv') # 整体缺失概览 def missing_overview(df): missing = df.isnull().sum() missing_pct = (missing / len(df)) * 100 missing_df = pd.DataFrame({ '缺失数量': missing, '缺失比例(%)': missing_pct.round(2), '数据类型': df.dtypes }) return missing_df[missing_df['缺失数量'] > 0].sort_values( '缺失比例(%)', ascending=False ) print(missing_overview(df))

然后看缺失的分布模式。用missingno库的bar图看各列缺失比例,用matrix图看缺失的行列分布,用heatmap图看缺失之间的相关性。

import missingno as msno import matplotlib.pyplot as plt fig, axes = plt.subplots(1, 3, figsize=(18, 5)) msno.bar(df, ax=axes[0]) axes[0].set_title('各字段缺失比例') msno.matrix(df, ax=axes[1]) axes[1].set_title('缺失值分布矩阵') msno.heatmap(df, ax=axes[2]) axes[2].set_title('缺失值相关性') plt.tight_layout() plt.show()

最后做缺失机制判断。把目标字段是否缺失作为分组变量,对比其他字段的分布差异:

from scipy import stats def test_mcar(df, target_col, test_cols): """检验缺失是否与其他变量相关""" df['_is_missing'] = df[target_col].isnull().astype(int) results = [] for col in test_cols: group0 = df[df['_is_missing'] == 0][col].dropna() group1 = df[df['_is_missing'] == 1][col].dropna() if df[col].dtype in ['float64', 'int64']: stat, p_value = stats.ttest_ind(group0, group1) test_type = 't检验' else: contingency = pd.crosstab(df['_is_missing'], df[col]) stat, p_value, _, _ = stats.chi2_contingency(contingency) test_type = '卡方检验' results.append({ '字段': col, '检验方法': test_type, 'p值': round(p_value, 4), '是否显著': '是' if p_value < 0.05 else '否' }) return pd.DataFrame(results)

如果p值小于0.05,说明缺失与该字段显著相关,缺失机制可能是MAR或MNAR,需要谨慎处理。

3.2 分场景处理策略与代码模板

根据诊断结果,我通常把字段分为四类,分别采用不同策略。

第一类:缺失率低于5%的字段。直接删除缺失行,或者用简单统计量填充。如果样本量充足,我倾向于删除,因为5%以下的缺失对整体分布影响很小。

# 缺失率低于5%:删除缺失行 low_missing_cols = missing_df[missing_df['缺失比例(%)'] < 5].index.tolist() df_clean = df.dropna(subset=low_missing_cols)

第二类:缺失率5%到30%的字段。数值字段用中位数填充加缺失标记,分类字段用分组众数填充。

# 中等缺失率:统计量填充 + 缺失标记 medium_missing_cols = missing_df[ (missing_df['缺失比例(%)'] >= 5) & (missing_df['缺失比例(%)'] < 30) ].index.tolist() for col in medium_missing_cols: df[f'{col}_missing'] = df[col].isnull().astype(int) if df[col].dtype in ['float64', 'int64']: df[col] = df[col].fillna(df[col].median()) else: df[col] = df[col].fillna(df[col].mode()[0])

第三类:缺失率30%到60%的字段。数值字段用模型预测填充,分类字段新增“未知”类别。

# 高缺失率:模型填充或新增类别 high_missing_cols = missing_df[ (missing_df['缺失比例(%)'] >= 30) & (missing_df['缺失比例(%)'] < 60) ].index.tolist() for col in high_missing_cols: if df[col].dtype in ['float64', 'int64']: # 用KNN填充 from sklearn.impute import KNNImputer imputer = KNNImputer(n_neighbors=5) df[[col]] = imputer.fit_transform(df[[col]]) else: df[col] = df[col].fillna('未知')

第四类:缺失率超过60%的字段。除非是核心业务字段,否则直接删除整列。

# 极高缺失率:删除整列 very_high_missing_cols = missing_df[ missing_df['缺失比例(%)'] >= 60 ].index.tolist() df = df.drop(columns=very_high_missing_cols)

3.3 填充效果验证与分布对比

填充完成后,必须验证填充效果。我通常从三个维度检查。

分布对比:填充前后的分布是否一致。用KDE图或直方图对比。

import seaborn as sns def compare_distribution(original, filled, col): fig, axes = plt.subplots(1, 2, figsize=(12, 4)) sns.kdeplot(original[col].dropna(), ax=axes[0], label='原始分布') sns.kdeplot(filled[col], ax=axes[0], label='填充后分布') axes[0].set_title(f'{col} 分布对比') axes[0].legend() sns.boxplot(data=[original[col].dropna(), filled[col]], ax=axes[1]) axes[1].set_xticklabels(['原始', '填充后']) axes[1].set_title(f'{col} 箱线图对比') plt.tight_layout() plt.show()

统计量对比:填充前后的均值、标准差、分位数是否接近。

def compare_stats(original, filled, col): stats_df = pd.DataFrame({ '原始': original[col].describe(), '填充后': filled[col].describe() }) stats_df['差异'] = stats_df['填充后'] - stats_df['原始'] return stats_df

模型效果验证:如果填充是为了后续建模,最终验证标准是模型效果。我通常做A/B对比:用删除法处理的数据训练一个模型,用填充法处理的数据训练另一个模型,对比验证集上的表现。

注意:填充效果验证必须在训练集上进行,不能用测试集验证填充策略,否则会造成数据泄露。

4. 常见问题与避坑指南

4.1 填充后数据泄露的隐蔽陷阱

数据泄露是缺失值处理中最容易犯的错误,而且往往很隐蔽。最常见的场景是:先在全量数据上计算中位数,再用这个中位数填充训练集和测试集。这会导致测试集的信息泄露到训练集中。

正确做法是:所有填充参数(中位数、众数、模型权重)必须只在训练集上计算,然后应用到测试集。

from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) # 正确:在训练集上计算中位数 median_values = X_train[numeric_cols].median() # 分别应用到训练集和测试集 X_train[numeric_cols] = X_train[numeric_cols].fillna(median_values) X_test[numeric_cols] = X_test[numeric_cols].fillna(median_values)

更规范的做法是用sklearn的Pipeline,把填充器封装进去:

from sklearn.pipeline import Pipeline from sklearn.impute import SimpleImputer from sklearn.ensemble import RandomForestClassifier pipeline = Pipeline([ ('imputer', SimpleImputer(strategy='median')), ('classifier', RandomForestClassifier()) ]) pipeline.fit(X_train, y_train) score = pipeline.score(X_test, y_test)

这样fit时只在训练集上计算填充参数,predict时自动应用到测试集,完全避免泄露。

4.2 分类字段填充的类别膨胀问题

分类字段填充时,如果新增“未知”类别,要警惕类别膨胀。比如一个城市字段有100个类别,缺失率30%,新增“未知”后变成101个类别。如果后续做独热编码,会多出100个稀疏特征。

我的处理原则是:如果某分类字段的类别数超过50个,且缺失率超过20%,优先考虑删除该字段或做类别合并。如果必须保留,用目标编码(Target Encoding)替代独热编码。

# 类别合并:把低频类别合并为“其他” def merge_rare_categories(series, threshold=0.01): freq = series.value_counts(normalize=True) rare_categories = freq[freq < threshold].index return series.replace(rare_categories, '其他') df['city'] = merge_rare_categories(df['city'])

4.3 时间序列填充的顺序错误

时间序列填充最常见的错误是:先填充再排序,或者用全局统计量填充时间序列。时间序列的填充必须严格按时间顺序进行。

# 错误做法:先填充再排序 df['value'] = df['value'].fillna(df['value'].median()) df = df.sort_values('timestamp') # 正确做法:先排序再填充 df = df.sort_values('timestamp') df['value'] = df['value'].fillna(method='ffill', limit=3)

另外,时间序列填充要避免使用未来信息。后向填充(bfill)在某些场景下会用到未来值,如果后续做预测模型,这会造成信息泄露。我的建议是:预测类任务只用前向填充,描述类任务可以用后向填充。

4.4 缺失值处理速查表

场景推荐策略注意事项
缺失率<5%,样本充足删除缺失行先划分训练测试集再删除
缺失率5%-30%,数值字段中位数填充+缺失标记偏态分布用中位数,正态分布用均值
缺失率5%-30%,分类字段分组众数填充检查填充后类别分布
缺失率30%-60%,数值字段KNN或模型填充计算成本高,注意过拟合
缺失率30%-60%,分类字段新增“未知”类别警惕类别膨胀
缺失率>60%删除整列除非是核心业务字段
时间序列前向填充+限制长度先排序,避免未来信息
重要特征高缺失模型预测填充只在训练集上训练填充模型

4.5 实操心得与经验总结

做了这么多年的数据清洗,我最大的体会是:缺失值处理没有标准答案,只有权衡。删有删的代价,填有填的风险。关键是要清楚每个决策背后的假设和影响。

几个我踩过的坑分享出来。第一个坑是盲目用均值填充所有数值字段,结果一个右偏的收入字段填充后均值被拉低,后续分析全部失真。第二个坑是分类字段用0填充,把“未知”和“类别0”混为一谈,模型学出了错误的模式。第三个坑是填充后没有做分布验证,直到模型效果异常才发现填充值偏离真实分布太远。

我的建议是:处理缺失值前先问自己三个问题。这个字段在业务上意味着什么?缺失可能是什么原因造成的?填充后的值如果偏离真实值,对后续分析影响有多大?想清楚这三个问题,策略自然就明确了。

另外,养成记录处理日志的习惯。每次填充用了什么方法、什么参数、填充了多少条记录,都记下来。后续模型效果异常时,这些日志是排查问题的重要线索。

# 处理日志模板 import logging from datetime import datetime logging.basicConfig( filename='missing_value_processing.log', level=logging.INFO, format='%(asctime)s - %(message)s' ) def log_processing(col, strategy, params, missing_count, total_count): logging.info( f"字段: {col} | 策略: {strategy} | 参数: {params} | " f"缺失数: {missing_count}/{total_count} " f"({missing_count/total_count*100:.2f}%)" )

最后再分享一个小技巧:对于重要字段,可以同时保留原始列和填充列。比如age和age_filled两列都保留,让模型自己决定用哪个。这样既保留了缺失信息,又提供了填充值,在某些场景下效果比单纯填充更好。

RELATED READING

延伸阅读

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