从银行贷款审批实战出发:5分钟搞懂机器学习里的数据清洗(缺失值处理篇)

银行风控建模的第一步,往往不是选择最炫酷的算法,而是处理那些让人头疼的缺失值。想象一下,你刚拿到一份银行贷款审批数据,准备构建预测模型时,却发现"年收入"一栏有15%的记录是空的——这就像厨师准备做菜时发现食材缺斤少两。本文将带你从银行业务视角,重新审视那些教科书式的填充方法。

1. 为什么银行数据清洗如此特殊?

在电商推荐系统中填充缺失值,和在银行贷款审批中处理缺失值,本质上是两种完全不同的游戏。前者可能影响用户体验,后者直接关系到金融公平与风险控制。

银行数据最显著的特征是 强监管属性 高价值密度 。每个缺失值背后可能隐藏着:

  • 客户故意隐瞒的高负债情况
  • 数据采集时的系统漏洞
  • 敏感信息的合规性过滤

以常见的收入字段缺失为例,直接使用均值填充可能导致:

  1. 低估高净值客户的实际风险
  2. 虚增低收入群体的信用评分
  3. 扭曲不同职业群体的收入分布
# 银行数据典型特征检查代码示例
def check_missing_pattern(df):
    missing_report = {
        'column': [],
        'missing_rate': [],
        'corr_with_approval': []  # 缺失是否与审批结果相关
    }
    for col in df.columns:
        missing_rate = df[col].isna().mean()
        # 检查缺失是否与贷款审批结果系统性相关
        corr = df[col].isna().astype(int).corr(df['y'])
        missing_report['column'].append(col)
        missing_report['missing_rate'].append(missing_rate)
        missing_report['corr_with_approval'].append(corr)
    return pd.DataFrame(missing_report)

提示:在金融领域,缺失本身可能就是重要特征。某些客户群体可能更倾向于隐瞒真实财务状况。

2. 数值型字段的处理艺术:超越简单均值

当面对x1-x6这类数值型变量(如收入、负债比、资产总值)时,专业数据分析师会考虑以下填充策略:

策略 适用场景 银行业务风险 实现代码示例
分位数填充 数据存在极端值时 避免异常值影响 df['income'].fillna(df['income'].quantile(0.3))
条件均值 字段间存在明显相关性 保持业务逻辑一致 df['income'] = df.groupby('job_title')['income'].transform(lambda x: x.fillna(x.mean()))
预测填充 高价值字段且缺失率低 计算成本较高但精确 使用随机森林回归预测缺失值
标记+中位数 缺失可能有特殊含义 保留缺失模式信息 df['income_missing'] = df['income'].isna()
df['income'] = df['income'].fillna(df['income'].median())

负债收入比(DTI)的典型处理流程:

  1. 计算非缺失客户的DTI分布百分位
  2. 分析缺失DTI客户的其他特征(职业、资产等)
  3. 根据客户分组的中位数进行有条件填充
  4. 添加缺失标记作为新特征
# 基于业务规则的条件填充示例
def fill_dti_smart(df):
    # 第一步:标记原始缺失情况
    df['dti_missing'] = df['dti'].isna().astype(int)
    
    # 第二步:按职业和资产分组填充
    fill_values = df.groupby(['job_category', 'has_mortgage'])['dti'].median()
    df['dti'] = df.apply(
        lambda row: fill_values.loc[row['job_category'], row['has_mortgage']] 
        if pd.isna(row['dti']) else row['dti'],
        axis=1
    )
    return df

3. 分类变量的智能处理:当众数不够用时

处理x7-x15这类分类变量(如职业、教育程度、居住城市)时,情况变得更加复杂。教育水平"未知"和"高中以下"在风控模型中的含义可能截然不同。

高级处理技巧包括:

  • 业务映射填充 :将缺失的职业映射为"自由职业"而非简单的众数
  • 概率抽样填充 :按现有分布概率随机填充,保持整体分布
  • 构建"缺失"类别 :特别是当缺失率超过5%时
  • 多层分组填充 :先按城市分组,再按收入水平填充教育程度

教育程度填充的典型错误与正确做法对比:

# 不推荐 - 简单使用全局众数
df['education'].fillna(df['education'].mode()[0], inplace=True)

# 推荐 - 考虑年龄分组的众数
age_bins = [20, 30, 40, 50, 60]
df['age_group'] = pd.cut(df['age'], bins=age_bins)
fill_values = df.groupby('age_group')['education'].agg(lambda x: x.mode()[0])
df['education'] = df.apply(
    lambda row: fill_values[row['age_group']] 
    if pd.isna(row['education']) else row['education'],
    axis=1
)

注意:对于婚姻状况等敏感字段,法律可能限制特定填充方式。在某些地区,不能将缺失的婚姻状况默认填充为"已婚"。

4. 从数据清洗到模型公平性:一个完整的案例

某城商行在消费贷审批模型中,最初对所有缺失的信用卡数量字段填充为0(假设没有信用卡)。上线后发现:

  1. 年轻客户群体审批通过率异常升高
  2. 农村地区客户被系统性低估信用
  3. 模型无法识别"无信用卡记录"与"确实没有信用卡"的区别

改进后的处理流程:

graph TD
    A[原始数据] --> B{信用卡字段缺失?}
    B -->|是| C[检查其他银行产品持有情况]
    B -->|否| D[保留原值]
    C --> E{有储蓄账户?}
    E -->|是| F[填充为同年龄段中位数]
    E -->|否| G[标记为"新客户"特殊类别]
    F --> H[添加"was_missing"标记]
    G --> H
    D --> H

调整后的模型效果对比:

指标 原始填充方式 优化后方案
年轻客户逾期率 4.2% 3.1%
农村客户通过率 58% 67%
模型AUC 0.72 0.78
客户投诉量 23件/月 7件/月

这个案例揭示了一个关键认知: 数据清洗不是单纯的数学问题,而是业务逻辑的编码过程 。银行风控模型中的每个填充决策,都应该能够通过"业务合理性测试"——即能够向监管机构合理解释为什么这样处理是公平且审慎的。

5. 实战工具箱:Python中的进阶技巧

超越简单的Scikit-learn Imputer,现代Python生态系统提供了更灵活的工具:

Pandas进阶填充方法:

# 基于时间序列的填充(适合信用卡还款记录)
df['payment_history'] = df.groupby('customer_id')['payment_history'].ffill()

# 使用插值法处理有序分类变量
df['credit_rank'] = df['credit_rank'].interpolate(method='polynomial', order=2)

# 多重填充(高级技巧)
from sklearn.experimental import enable_iterative_imputer
from sklearn.impute import IterativeImputer
imp = IterativeImputer(max_iter=10, random_state=0)
df[['income', 'assets']] = imp.fit_transform(df[['income', 'assets']])

处理特殊业务场景的函数封装:

def financial_imputer(df, num_strategy='median', cat_strategy='business_rules'):
    """
    针对金融数据特点的智能填充器
    
    参数:
        num_strategy: 数值型字段策略 ('median', 'grouped_mean', 'quantile')
        cat_strategy: 分类型字段策略 ('business_rules', 'probabilistic')
    """
    # 数值型处理
    if num_strategy == 'grouped_mean':
        for col in NUMERIC_COLS:
            df[col] = df.groupby('occupation')[col].transform(
                lambda x: x.fillna(x.mean()))
    
    # 分类变量处理
    if cat_strategy == 'business_rules':
        df['education'] = df.apply(fill_education_by_age, axis=1)
        df['marital_status'] = df['marital_status'].fillna('Unknown')
    
    # 添加缺失标记
    for col in df.columns:
        if df[col].isna().sum() > 0:
            df[f'{col}_was_missing'] = df[col].isna()
    
    return df

在Jupyter Notebook中进行填充效果验证的实用代码块:

# 填充前后分布对比可视化
import matplotlib.pyplot as plt

fig, axes = plt.subplots(1, 2, figsize=(12, 5))
df['income'].plot(kind='kde', ax=axes[0], title='Before Imputation')
df_filled['income'].plot(kind='kde', ax=axes[1], title='After Imputation')
for ax in axes:
    ax.set_xlabel('Income')
plt.tight_layout()
plt.show()

# 填充值与实际值差异分析
mask = df_original['income'].notna() & df_filled['income_was_missing']
errors = df_original.loc[mask, 'income'] - df_filled.loc[mask, 'income']
print(f"平均填充误差: {errors.abs().mean():.2f}")
print(f"最大高估: {errors.max():.2f}")
print(f"最大低估: {errors.min():.2f}")

数据清洗的质量直接影响模型效果。在一次实际项目中,经过优化的填充策略使KS值从0.32提升到了0.41,而整个过程中使用的仍然是相同的XGBoost算法和特征工程流程。这印证了业界常说的那句话:"垃圾进,垃圾出"——再先进的算法也无法弥补低质量的数据准备。

更多推荐