数据预处理:缺失值处理、异常值处理、数据类型转换

数据预处理这一步,说白了就是给数据"洗澡"。我记得刚入行时接过一个信贷数据集,缺失率超过40%,我当时直接删了半张表……结果模型KS只有0.08,领导把我叫去谈了半小时。后来才明白,预处理不是简单的"删"或"填",每一步都得有道理,尤其是在信用评分卡里,特征分箱的质量很大程度上取决于预处理阶段的功夫。

下面这张图是我自己总结的预处理流程,你可以当个路线图用。每个环节做什么、怎么衔接,看完心里就有数了。

数据预处理核心流程(信用评分卡场景) 原始数据 含缺失、异常、格式不统一 缺失值处理 删除 / 均值/中位数填充 / 模型预测 异常值处理 IQR规则 / Z-Score / 业务阈值截尾 数据类型转换 数值化 / Label编码 / 时间解析 建模就绪数据 干净、规范、可直接入模分箱

1. 缺失值处理:别急着删,先看缺失逻辑

信用评分卡里,缺失值太常见了。年龄、收入、工作年限……总有一些字段是空的。我个人习惯第一步先算缺失率,分三类对待:

  • 缺失率 < 5%:直接删除样本,对整体影响不大。
  • 缺失率 5% - 30%:用中位数或均值填充。收入这类偏态分布用中位数,年龄用均值。
  • 缺失率 > 30%:别随便填了。我会把"是否缺失"单独做成一个二值特征,再配合模型预测填充。

我在项目里遇到过一个问题:一个"负债率"特征缺失率35%,我直接用均值填充,结果分箱后坏样本率曲线出现了奇怪的"驼峰"。后来排查发现,缺失的样本其实是"收入为0"的隐性失业人群,跟有收入的人群根本不是一回事。你想想看,这种场景下缺失本身就是信息。

我的建议:信用评分卡中,对于缺失率较高的特征,先问自己"缺失是随机发生的,还是存在某种业务含义?"如果是后者,把缺失单独设为一箱往往比分箱前填充更合理。
# 缺失值处理典型代码
import pandas as pd
import numpy as np

# 1. 查看缺失率
missing_rate = df.isnull().mean()
print(missing_rate[missing_rate > 0])

# 2. 按缺失率分类处理
for col in missing_rate.index:
    rate = missing_rate[col]
    if rate < 0.05:
        df.dropna(subset=[col], inplace=True)
    elif rate < 0.30:
        if df[col].skew() > 1:  # 偏态分布用中位数
            df[col].fillna(df[col].median(), inplace=True)
        else:
            df[col].fillna(df[col].mean(), inplace=True)
    else:
        # 高缺失率:生成缺失标记 + 模型预测填充
        df[col + '_missing'] = df[col].isnull().astype(int)
        # 后续可用简单模型预测填充,此处略
        pass

2. 异常值处理:别让"极端值"带偏了分箱边界

异常值对分箱的影响,比缺失值更隐蔽。我之前做过一个项目,收入特征里有个值是980万,明显是录入错误。我没处理直接分箱,结果最后一箱的边界被拉到500万以上,整张评分卡就废了。

检测异常值,我常用这三种方法:

  1. IQR 规则:下界 Q1 - 1.5×IQR,上界 Q3 + 1.5×IQR。适用于偏态分布。
  2. Z-Score 方法:|Z| > 3 视为异常。适用于近似正态分布。
  3. 业务阈值:比如负债率不可能超过100%,年龄不可能超过120岁。这个最靠谱。

处理方式上,我倾向于"截尾"而不是"删除"。你想想看,删除样本会损失信息,尤其是信用评分卡这种正负样本本来就稀缺的场景。把异常值拉到上下边界,既保留了样本量,又不让极端值影响分箱。

我曾经踩过的坑:有一次做"收入"特征分箱,我用IQR检测出上界是35万,直接把高于35万的全部删了。结果模型上线后,高收入群体的评分严重偏高,因为删掉的都是好客户。后来改成截尾处理,KS提升了0.06。记住:异常值不等于错误值,尤其是业务上的"真实极端值"。
# 异常值检测与截尾处理
def cap_outliers(df, col, method='iqr'):
    if method == 'iqr':
        Q1 = df[col].quantile(0.25)
        Q3 = df[col].quantile(0.75)
        IQR = Q3 - Q1
        lower = Q1 - 1.5 * IQR
        upper = Q3 + 1.5 * IQR
    elif method == 'zscore':
        mean, std = df[col].mean(), df[col].std()
        lower = mean - 3 * std
        upper = mean + 3 * std
    else:
        # 业务阈值需手动传入
        raise ValueError("请指定业务阈值")

    df[col] = df[col].clip(lower=lower, upper=upper)
    return df

# 使用示例
df = cap_outliers(df, 'income', method='iqr')
df = cap_outliers(df, 'age', method='zscore')

3. 数据类型转换:分箱前必须做好的"格式清理"

数据类型转换看起来简单,但最容易翻车。信用评分卡里的特征,来源五花八门:前端录入、后端数据库、第三方数据源……类型不一致是家常便饭。

我把常见的数据类型转换场景整理成了下面这个表,你直接对照着用:

原始类型 目标类型 转换方法 信用评分卡典型特征
object(字符串数字) float / int pd.to_numeric() 收入、年龄、负债率
object(类别) category astype('category') + LabelEncoder 职业、教育程度、婚姻状况
object(日期) datetime pd.to_datetime() 申请日期、逾期日期
int(类别编码) category astype('category') 性别编码、区域编码

这里有个我反复踩过的坑:日期格式不统一。有的系统用"2024-01-15",有的用"2024/01/15",还有用"20240115"的。直接用 pd.to_datetime() 会报错,必须先指定格式或做归一化。

# 数据类型转换示例
# 1. 字符串转数值
df['income'] = pd.to_numeric(df['income'], errors='coerce')

# 2. 类别编码
from sklearn.preprocessing import LabelEncoder
le = LabelEncoder()
df['education'] = le.fit_transform(df['education'].astype(str))

# 3. 日期统一解析
df['apply_date'] = pd.to_datetime(df['apply_date'], format='%Y-%m-%d', errors='coerce')

# 4. 检查转换后的类型
print(df.dtypes)

关于数据类型转换,我想再叮嘱一句:转换完成后,务必用 df.info() 和 df.isnull().sum() 再检查一遍。因为转换过程中 errors='coerce' 会把解析失败的变成 NaN,如果你没注意,这些 NaN 就会在后续分箱时悄悄捣乱。

预处理做完,数据基本就"干净"了。下一步就是进入特征分箱环节——但那是后面几章的事。你现在只需要记住:预处理的质量,决定了分箱的天花板。我见过太多人急着分箱,结果被缺失值和异常值坑得反复返工。打好基础,后面才能走得快。