第3章 数据清洗:先把数据“洗”干净
拿到原始数据,第一件事不是建模,是清洗。我见过太多人上来就怼模型,结果跑出来一堆莫名其妙的系数——后来一查,缺失值没处理,异常值在那顶着,重复行还翻了两倍。说白了,垃圾进垃圾出。
信用评分卡对数据质量尤其敏感。一个缺失的字段,可能让客户的评分偏了50分。所以这一章,咱们就老老实实把数据洗干净。我个人习惯是:先看结构,再逐项清理。
3.1 缺失值处理
缺失值在真实数据里太常见了。客户不填收入、银行没有历史逾期记录……这些都是家常便饭。怎么搞?三个思路:删、填、猜。
| 方法 | 适用场景 | 风险点 |
|---|---|---|
| 直接删除 | 缺失比例<5%,且随机缺失 | 样本量减少,可能引入偏差 |
| 均值/中位数填充 | 连续变量,缺失比例不高 | 可能拉低方差,影响模型区分能力 |
| 众数填充 | 分类变量,缺失比例小 | 可能掩盖真实分布 |
| 前向/后向填充 | 时间序列或有序数据 | 对非时序数据不适用 |
| 插值法 | 线性趋势明显的数据 | 计算量大,可能过拟合 |
| 模型预测填充 | 缺失值与其他特征高度相关 | 容易引入数据泄露 |
我在项目中遇到过一笔信贷数据,年收入字段缺失了20%。直接删除?样本不够。均值填充?会造成收入分布严重左偏。后来我用了分组填充——按职业和学历分组,用组内中位数填。效果好了不少。
重要原则:训练集和测试集的填充参数必须一致。比如训练集算出的中位数,要存下来给测试集用。
来看代码。用pandas处理缺失值非常方便:
import pandas as pd
import numpy as np
# 假设 df 是原始数据
# 1. 查看缺失情况
print(df.isnull().sum())
# 2. 删除缺失比例超过50%的列
df.dropna(thresh=len(df)*0.5, axis=1, inplace=True)
# 3. 连续变量用中位数填充
med_income = df['income'].median()
df['income'].fillna(med_income, inplace=True)
# 4. 分类变量用众数填充
mode_edu = df['education'].mode()[0]
df['education'].fillna(mode_edu, inplace=True)
# 5. 时间序列用前向填充
df['last_payment_date'].fillna(method='ffill', inplace=True)
避坑指南:我曾经用均值填充了一个极度偏态的变量——信用卡额度。结果模型训练时拟合得很好,上线后预测一塌糊涂。后来换成中位数填充才正常。所以一定要先看分布,再选填充策略。
3.2 异常值检测与处理
异常值在信用数据里特别要命。一个客户的年龄填了200岁,年收入填了10亿,这明显有问题。你想想看,如果不处理,模型会拼命去拟合这些“假大款”,真实规律反而被淹没。
常用的方法就三个:
- 3σ原则:适合正态分布的数据,超过均值±3倍标准差的值视为异常。
- IQR(四分位距):适合偏态分布,超过Q1-1.5IQR或Q3+1.5IQR的值是异常。
- 业务规则:比如年龄<18或>100,直接标记为异常。
我个人偏好IQR法,因为它对分布形状不敏感。看代码:
def detect_outliers_iqr(df, column):
Q1 = df[column].quantile(0.25)
Q3 = df[column].quantile(0.75)
IQR = Q3 - Q1
lower = Q1 - 1.5 * IQR
upper = Q3 + 1.5 * IQR
return df[(df[column] < lower) | (df[column] > upper)].index
out_idx = detect_outliers_iqr(df, 'loan_amount')
print(f'发现 {len(out_idx)} 个异常值')
# 处理:建议先标记,不要直接删除
df['is_outlier'] = False
df.loc[out_idx, 'is_outlier'] = True
# 对于连续变量,可用上下限截断(盖帽法)
q_low = df['loan_amount'].quantile(0.01)
q_high = df['loan_amount'].quantile(0.99)
df['loan_amount'] = df['loan_amount'].clip(lower=q_low, upper=q_high)
经验之谈:异常值不总是“错”的数据。高收入客户可能真的有超高贷款,你需要结合业务判断。我一般会先用IQR标记,再人工抽样查看,最后决定是剔除还是盖帽。
3.3 重复值处理
重复记录在合并数据或导入过程中很常见。客户多次提交申请,或者ETL程序出bug,都会造成重复。处理起来倒简单:
# 查看重复行数
print(df.duplicated().sum())
# 基于所有列去重
df.drop_duplicates(inplace=True)
# 基于指定列去重(比如客户ID + 申请日期)
df.drop_duplicates(subset=['customer_id', 'apply_date'], keep='last', inplace=True)
这里要注意,keep='last'保留最后一条记录。如果你想要保留最早的那条,就用keep='first'。具体情况看业务逻辑。
3.4 统一数据类型
洗数据的最后一步,就是让每个字段的类型都对。我见过把金额存成字符串的,把日期存成object的……不转类型,后面分箱、WOE编码都跑不起来。
| 目标类型 | 常见方法 | 示例 |
|---|---|---|
| 数值型 | pd.to_numeric() | df['age'] = pd.to_numeric(df['age'], errors='coerce') |
| 分类型 | astype('category') | df['gender'] = df['gender'].astype('category') |
| 日期时间 | pd.to_datetime() | df['apply_date'] = pd.to_datetime(df['apply_date']) |
# 批量转换数值列
num_cols = ['income', 'loan_amount', 'age']
for col in num_cols:
df[col] = pd.to_numeric(df[col], errors='coerce')
# 把日期统一
df['apply_date'] = pd.to_datetime(df['apply_date'], format='%Y%m%d', errors='coerce')
# 把性别、学历转成分类
cat_cols = ['gender', 'education', 'marital_status']
for col in cat_cols:
df[col] = df[col].astype('category')
转换时别忘了加errors='coerce'。否则遇到非法值会直接报错,中断整个流程。我习惯先做一个类型检查:
print(df.dtypes) # 看看还有哪些object没转
数据清洗总体流程
把上面几步串起来,就是一张完整的清洗流程图。你看:
建议:清洗代码写成函数,每次都跑一遍。这样换数据源时只需要改参数,不用改逻辑。我自己维护了一个 data_cleaner.py 脚本,包含了上面所有流程,跑起来特别省心。
好了,数据洗完了。下一站,就可以放心大胆地做特征工程了。