第四章 数据预处理(上):数据清洗与缺失异常值处理
各位同学,咱们今天聊聊数据预处理。说实话,这步看着不起眼,但在我做过的几十个评分卡项目里,80%的模型翻车都出在数据预处理上。你想想看,模型再牛,喂进去的是垃圾,吐出来的能是黄金吗?
核心观点:数据预处理不是体力活,是决定模型上限的关键环节。我见过太多人急着调参,结果数据里一堆坑,最后模型上线就崩。
4.1 数据清洗:别让脏数据毁了你的模型
数据清洗,说白了就是给数据「洗澡」。我刚开始做风控那会儿,接过一个项目,数据量看着挺大,结果一跑模型,AUC只有0.52。查了半天,发现是重复数据占了30%。你说气不气人?
4.1.1 去重处理
重复数据在申请评分卡里特别常见。比如同一个客户申请了两次,或者系统抽风多录了一条。我个人习惯分三步走:
- 完全重复检测:所有字段一模一样,直接删掉一条
- 关键字段重复:身份证号、手机号相同,但其他字段不同——这时候要保留最新的那条
- 业务逻辑去重:比如同一个人同一天申请多次,只保留第一次
我的小技巧:去重之前,先看看重复数据的占比。如果超过5%,建议先排查数据采集环节是不是出了问题。我曾经遇到过一个项目,重复率高达40%,最后发现是接口调用逻辑写错了。
# Python 示例:去重处理
import pandas as pd
# 完全去重
df_clean = df.drop_duplicates()
# 按身份证去重,保留最新记录
df_clean = df.sort_values('apply_time').drop_duplicates(subset='id_card', keep='last')
# 查看重复率
dup_rate = (df.duplicated().sum() / len(df)) * 100
print(f'重复率:{dup_rate:.2f}%')
4.1.2 格式统一
格式问题看着小,坑起来真要命。我记得有一次,一个同事跑模型一直报错,查了两天才发现——身份证号有的带X,有的带x,有的中间有空格。你说冤不冤?
我一般会做这几件事:
- 字符统一:全部转大写或小写,去掉首尾空格
- 日期格式:统一成 YYYY-MM-DD,别搞什么 2024/01/01 或 01-01-2024
- 数值格式:去掉千分位逗号,统一小数位数
- 分类变量:把「男」「M」「男性」统一成「男」
注意:格式统一一定要在去重之后做。为什么?因为格式不统一的时候,你根本看不出来哪些是重复数据。比如「张三」和「张三 」在去重时会被当成两条不同的记录。
4.2 缺失值处理:填还是不填,这是个问题
缺失值处理,是数据预处理里最考验经验的环节。你想想看,一个字段缺失率80%,你硬填进去,那不是自欺欺人吗?
4.2.1 先判断缺失类型
我个人习惯,拿到数据先看缺失率。然后分三种情况:
| 缺失率 | 处理方式 | 我的经验 |
|---|---|---|
| < 5% | 直接删除或简单填充 | 删掉影响不大,但要注意样本量 |
| 5% - 30% | 均值/中位数/模型填充 | 这是最常见的区间,要仔细处理 |
| > 30% | 考虑删除该变量 | 或者做成「是否缺失」的哑变量 |
4.2.2 均值/中位数/众数填充
这是最基础的方法,简单粗暴。但要注意:
- 均值填充:适合正态分布的数据,比如收入。但如果有异常值,均值会被拉偏
- 中位数填充:适合偏态分布,比如贷款金额。我一般优先用中位数,因为它更稳健
- 众数填充:适合分类变量,比如学历、职业
关键点:填充之前,一定要先分组。比如按「是否逾期」分组后,分别计算好客户和坏客户的中位数,然后各自填充。你想想看,好客户和坏客户的收入能一样吗?
# 分组填充示例
# 按目标变量分组后,用各组的中位数填充
df['income_filled'] = df.groupby('target')['income'].transform(
lambda x: x.fillna(x.median())
)
4.2.3 模型预测填充
这个方法我特别喜欢,尤其是在做评分卡的时候。说白了,就是用其他字段来预测缺失的那个字段。
具体怎么做?
- 把数据分成两部分:有缺失值的和没缺失值的
- 用没缺失值的数据训练一个模型(比如随机森林、XGBoost)
- 用训练好的模型预测缺失值
避坑指南:我曾经犯过一个错误——用目标变量(y)来预测缺失值。结果模型过拟合得一塌糊涂。记住,预测缺失值时,只能用特征变量,不能用目标变量。否则就是数据泄露。
# 模型预测填充示例
from sklearn.ensemble import RandomForestRegressor
# 分离有缺失和没缺失的数据
not_missing = df[df['income'].notna()]
missing = df[df['income'].isna()]
# 训练模型
features = ['age', 'education', 'work_years', 'debt_ratio']
model = RandomForestRegressor()
model.fit(not_missing[features], not_missing['income'])
# 预测缺失值
df.loc[df['income'].isna(), 'income'] = model.predict(missing[features])
4.3 异常值处理:别让极端值带偏了模型
异常值,说白了就是那些「不正常」的数据。比如一个20岁的人年收入1000万,你觉得正常吗?
4.3.1 盖帽法
盖帽法,也叫截尾处理。就是把超过某个阈值的值,强行拉回到阈值上。
我一般用百分位数来定阈值:
- 下界:1%分位数或5%分位数
- 上界:99%分位数或95%分位数
举个例子,收入的99%分位数是50万,那所有超过50万的收入,都改成50万。这样做的好处是保留了样本量,不会因为删掉异常值而损失数据。
注意:盖帽法的阈值不是固定的。我建议你先画个箱线图看看数据分布。如果异常值特别多,可能是数据本身有问题,而不是真正的异常。
# 盖帽法示例
def cap_outliers(df, column, lower_percentile=0.01, upper_percentile=0.99):
lower = df[column].quantile(lower_percentile)
upper = df[column].quantile(upper_percentile)
df[column] = df[column].clip(lower, upper)
return df
# 对收入字段做盖帽处理
df = cap_outliers(df, 'income', 0.01, 0.99)
4.3.2 分箱法
分箱法,就是把连续变量变成离散变量。比如把年龄分成「18-25」「26-35」「36-45」「46-55」「55+」这几个区间。
为什么这么做?有两个好处:
- 降低异常值影响:极端年龄和正常年龄被分到同一个箱子里,影响就小了
- 提升模型稳定性:评分卡里用的就是分箱后的WOE值,这是标准做法
我的经验:分箱不是随便分的。我一般会结合业务逻辑和统计检验。比如年龄分箱,我会看看每个箱子里好客户和坏客户的比例,确保分箱有意义。如果某个箱子里全是好客户,那这个箱子可能分得太细了。
# 分箱法示例
import numpy as np
# 等距分箱
df['age_bin'] = pd.cut(df['age'], bins=[18, 25, 35, 45, 55, 100],
labels=['18-25', '26-35', '36-45', '46-55', '55+'])
# 等频分箱(每个箱子里样本量差不多)
df['income_bin'] = pd.qcut(df['income'], q=5, labels=['Q1', 'Q2', 'Q3', 'Q4', 'Q5'])
4.4 本章小结
数据预处理这块,说白了就是「磨刀不误砍柴工」。我见过太多人急着建模,结果数据预处理草草了事,最后模型效果不好,回头又花大把时间调参。其实问题根本不在模型,在数据。
记住三个原则:
- 先清洗,再处理:去重和格式统一是第一步
- 先分析,再填充:缺失值处理前,先看看缺失率和缺失模式
- 先理解,再截尾:异常值处理前,先理解业务含义
好了,这一章的内容就到这儿。数据预处理的下半部分,咱们会聊特征工程和变量筛选,那才是真正考验功力的地方。