3. 数据清洗:缺失值处理

数据清洗这件事,其实是整个特征工程里最枯燥、但又最关键的环节。我做了这么多年信用评分项目,发现一个规律:模型好不好,往往不取决于算法多高级,而取决于你数据修得干不干净。说得直白点,缺失值处理不好,后面再怎么调参都是白搭。

3.1 先摸清家底——统计缺失率

拿到一份原始数据,我的第一个动作不是跑模型,而是跑一个缺失率统计。你想想看,如果你的收入字段有 80% 是空的,还拿它做特征,那不是自己骗自己吗?

一般我会按字段粒度统计缺失比例,然后分类处理:

缺失率范围 处理策略 我的个人经验
< 5% 直接填均值/中位数,或者直接删行 信用评分样本量通常大,删几行无所谓,我一般直接删
5% ~ 30% 考虑均值/中位数/众数填充 这里是填充策略的主要战场,下面会细说
30% ~ 60% 模型预测填充,或做"是否缺失"二值特征 我曾经踩过坑,30%以上用简单填充会把分布搞崩
> 60% 基本可以放弃该字段 除非是强业务特征,否则我直接丢弃

统计缺失率的代码很简单,但效果很好:

import pandas as pd

def missing_rate(df):
    miss = df.isnull().sum()
    miss_pct = (miss / len(df)) * 100
    miss_df = pd.DataFrame({'缺失数': miss, '缺失率%': miss_pct})
    return miss_df[miss_df['缺失数'] > 0].sort_values('缺失率%', ascending=False)

# 用法示例
# miss_report = missing_rate(credit_data)
# print(miss_report)
小提示:我习惯把这个缺失率报告输出成Excel,发给业务同事再确认一遍。有些字段缺失率高是因为业务原因,不是数据问题,要结合业务判断。

3.2 简单填充:均值、中位数、众数

做信用评分时,很多字段是连续型的,比如年收入、负债比、征信查询次数。对这些字段,我一般这样选填充策略:

  • 均值填充:适合正态分布的数据。但信用评分的收入往往右偏,用均值会被高收入拉偏。我很少直接用均值填充收入,除非数据集非常大。
  • 中位数填充:我个人的首选。它对异常值不敏感,特别适合信用评分这种长尾分布。我曾经对比过,用中位数填充比均值填充在AUC上能高出0.02~0.03。
  • 众数填充:仅用于类别型字段,比如"住房类型""教育程度"。这一点要注意——众数填充可能导致某个类别占比过高,所以我会加一个"缺失是否"标识。

看代码:

# 中位数填充连续字段
credit_data['年收入'].fillna(credit_data['年收入'].median(), inplace=True)

# 众数填充类别字段
credit_data['住房类型'].fillna(credit_data['住房类型'].mode()[0], inplace=True)

嗯,这里要注意,mode() 返回的是 Series,一定要取第一个值,否则会报错。我之前就忘记取 [0],结果填充进去一个 Series,排查了好久。

3.3 模型预测填充——当简单方法不够用时

当某个特征的缺失率超过 30% 时,简单填充会严重扭曲数据分布。这时候我建议用模型预测填充。说白了,就是把缺失的字段作为目标变量,用其他完整字段来预测它。

我在项目中常用的做法:

  1. 把原始数据分成两份:一份无缺失(训练集),一份有缺失(预测集)。
  2. 用无缺失部分训练一个回归模型(常用随机森林,因为它不用太多调参)。
  3. 对缺失部分做预测,把预测值填进去。

当然,这个方法的代价是计算量大。如果你的样本量上百万,用随机森林可能会跑很久。我会退而求其次,用 KNN Imputer 或者 Iterative Imputer(scikit-learn 里都有)。

避坑指南:我曾经在一个信贷数据集上用模型预测填充 "负债比",结果发现预测值跟实际有系统性偏差——因为负债比这个字段本身参与了许多其他特征的计算,导致信息泄露。后来我把它拆成两个步骤:先用没有负债比参与的特征预测,再回算。记住:用来预测的特征不能和目标字段高度线性相关。

代码示例:

from sklearn.ensemble import RandomForestRegressor

# 分离数据
train = credit_data[credit_data['负债比'].notna()]
predict = credit_data[credit_data['负债比'].isna()]

# 选择特征(排除目标列)
features = ['年收入', '年龄', '征信查询次数']
X_train = train[features]
y_train = train['负债比']
X_predict = predict[features]

# 训练并预测
model = RandomForestRegressor(n_estimators=100, random_state=42)
model.fit(X_train, y_train)
predicted_values = model.predict(X_predict)

# 填入缺失位置
credit_data.loc[credit_data['负债比'].isna(), '负债比'] = predicted_values

3.4 整体流程图

下面这张图总结了我处理缺失值的完整决策路径,你可以把它贴在工位上,每次遇到缺失值照着走一遍:

缺失值处理决策流程(信用评分场景) 原始数据 统计缺失率 缺失率 如何? <5% 直接删除 / 均值填充 5%~30% 中位数/众数填充 >30% 模型预测填充 / 丢弃 清洗完成,进入特征工程 * 中位数填充适用于连续型字段 * 模型预测填充后建议保留“是否缺失”标识

3.5 别忘了标记——"缺失"本身也是信息

最后,我想跟你分享一个我自己的经验。很多人做完填充就完事了,其实缺失本身有时候比填充值更有价值。在信用评分的场景里,某个字段缺失往往暗示着这个人的信息不完整,可能风险偏高。

我个人习惯:无论用什么方式填充,我都会额外生成一个二值特征,比如 年收入_缺失标志,缺失时为1,否则为0。这个标志在某些模型(尤其是树模型)里经常被选中作为重要特征。

注意:如果缺失率非常低(<1%),这个二值标志几乎没有方差,加了也没用,可以省略。

代码实现:

# 为每个有缺失的字段生成标志
for col in ['年收入', '负债比', '住房类型']:
    credit_data[f'{col}_缺失标志'] = credit_data[col].isna().astype(int)

好了,关于缺失值处理,核心就这么几招。你先按缺失率分档,再选合适的填充策略,千万别忘了做缺失标志。把这一步做好,后面的特征工程才能站得住脚。