☰
特征工程实战:从原始信贷数据到贷款违约预测入模特征
2026/10/15 9:15:07 网站建设 项目流程

做数据分析的人常说,拿到一份数据,最费时间的不是建模,而是洗数据和造特征。放在金融风控的贷款违约预测里,这句话还要再加一个“更”字。特征工程做得好不好,直接决定了模型的上限,算法调参只是在同一个天花板下面来回腾挪。这一篇是数据挖掘实践课程里贷款违约预测系列的第三篇,主题就是特征工程。我会把从原始信贷数据到最终入模特征的完整流程拆开讲,包括缺失值处理、异常值修正、分箱与WOE/IV、目标编码,以及怎么避开数据泄漏这种隐形炸弹。适合正在做金融风控建模的同学,也适合想参加类似数据挖掘竞赛但不知道从哪下手的读者。看完这一篇,你应该能直接照着一套流程,把自己的特征集跑出来。

1. 特征工程在贷款违约预测中的定位与整体思路

1.1 为什么金融风控场景下特征工程尤其重要

很多人一拿到数据就想调参,其实在金融风控这个场景,模型的差距往往不是算法本身造成的,而是特征工程。算法大家都熟:逻辑回归、XGBoost、LightGBM,超参数就那么多,哪怕你把网格搜索跑出花来,天花板是固定的。特征就不同了,同样的数据,不同人做出来的特征集,信息量可能差别巨大。

风控数据的特殊性在于,它天生就是为“审批决策”设计的,而不是为“预测建模”设计的。举个例子,原始表里通常有“月收入”和“每月还款额”两个字段,单独看哪一个都说明不了问题,但如果构造一个“月还款额 / 月收入”的比例,也就是负债收入比(DTI),这个特征对违约率的区分能力立刻就不一样了。收入高不代表能还,负债更高照样爆;收入低但负债极低,违约风险反而可控。这种业务含义很强的组合特征,就是特征工程在风控里的核心价值。

再往深一层说,金融风控模型还要求特征基本可解释、业务上站得住脚。你可能在一个竞赛里用一堆不知所谓的特征堆出很高的分数,但在真实风控环境里,这种特征是过不了评审的。监管、审计、业务运营都会问:这特征是什么意思?为什么逾期的人在这个区间比例特别高?如果答不上来,特征再能打也不敢用。所以风控特征工程不是简单的技术活,而是技术和业务逻辑的交叉。

1.2 特征工程的标准流程:从原始表到建模集

我习惯把特征工程拆成六个环节,每个环节都有明确产出,别跳步。

第一是业务理解。先搞清楚数据集里每个字段是怎么产生的,有没有审批流程、风控策略相关的背景。这一步是隐形的,但最值钱。第二是数据清洗。处理重复样本、明显错误值、缺失值。第三是基础特征。把原始字段转换成模型能直接吃的干净字段,比如数值标准化、类别编码。第四是特征衍生。基于业务逻辑创建组合特征,这部分信息增益最大。第五是特征筛选。去掉冗余的、无预测力的、可能泄漏的特征,控制维度。第六是稳定性检查。尤其在真实风控中,要评估特征的PSI(人群稳定性指标),防止上线后特征分布漂移。竞赛里这一步可以简化为时间上的稳定性验证。

这个顺序不建议打乱。比如你还没做缺失值处理就急着做分箱,分箱出来的区间边界全被缺失值带偏;还没做特征筛选就上模型,LightGBM倒是不怕冗余,但解释性和稳定性会出问题。每一步都在给下一步打地基。

1.3 竞赛环境与生产环境的差异,提前说透

做数据挖掘竞赛和做真实信贷风控,在特征工程上的关注点并不完全一样。竞赛里,你只需要让模型在测试集上表现好,样本分布是给定的;而生产环境里,你要考虑特征在未来几个月会不会失效、线上缺失值会不会变多、某个类别会不会突然消失,还要考虑特征采集是否合规。

所以在写特征工程代码的时候,从一开始就要养成两个习惯:第一,所有统计量(均值、中位数、分位数、目标编码映射)都必须只用训练集计算,测试集只做映射,坚决不使用全量数据计算。第二,验证特征效果时,尽量按时间顺序划分训练集和验证集,而不是随机切分。信贷客群的构成会随宏观经济和获客渠道变化,随机划分得到的好成绩往往带有时间穿越的水分。这两个习惯能在后面帮你省下无数麻烦。

2. 核心特征处理方法:从原始字段到可用特征

2.1 缺失值处理的核心:先问为什么缺失

信贷数据里的缺失值,几乎没有一个是“随机丢失”的。比如“距最近一次逾期月数”这个字段,如果用户之前从来没有逾期记录,这笔数据很可能就是空的,这时候用“0”去填就完全错了,应该用一个很大的值(比如999个月)表示“从未逾期”。再比如“收入验证状态”缺失,可能意味着当时审批流程里没有做核验环节,这类客群的质量本身就跟验证过的客群不一样。

我的常规做法是分四步:

  1. 算每个字段的缺失率。缺失率超过80%,一般直接丢弃,除非业务上特别重要。
  2. 区分缺失类型。能推断出业务含义的,要专门设计填充值。
  3. 对于连续型变量,优先用中位数填充,而不是均值。因为信贷特征普遍右偏,均值会被极端值拉高,中位数更稳健。
  4. 对每个有缺失的字段,额外增加一个“是否缺失”的二值标志,给模型一个机会去学习缺失本身的信息。

为什么要加缺失标志?因为缺失模式往往和申请渠道、客群质量相关。比如某个字段是线下渠道才会采集的,线上渠道是空的,那这个缺失标志其实是在替“渠道”这个信息代言。你不加这个标志,模型就只能从其他特征里间接学,效率低得多。

举个例子,某数据集中,“工作年限”缺失率有15%,我发现缺失样本的违约率是正常样本的1.8倍。后来了解到,缺失主要是因为一些小企业主没有固定受雇工作,收入结构不一样,所以风险偏高。这种情况下,缺失标志的价值比把缺失值填得多精确都大。

2.2 异常值与逻辑校验:不一定要删,但要管

信贷特征里的异常值,很多并不是“数据出错”,而是极端风险个体。比如收入填了5000万,有可能是把公司资产填进去了;年龄填了110岁,多半是录入错误。处理异常值要分情况:

  • 明显不合逻辑的,直接删除或修正。年龄小于18或大于100、贷款金额小于等于0、利率为负,这些都是脏数据。
  • 极端但真实存在的,建议做分位数截断,也叫缩尾处理。比如把年收入上下限分别截断到1%和99%分位数,避免极端值干扰线性模型。
  • 有偏分布特别严重的连续特征,比如收入、贷款金额,可以考虑做log1p变换,让分布更接近正态。树模型不像线性模型那么依赖这个,但做了之后对特征的可视化和分箱都有帮助。

需要特别提醒的是,不要因为某个值“看起来太大”就把它删掉。在风控中,极端值往往代表一类极端客群,直接删掉等于把风险信息扔了。合理做法是先做分位数截断,再结合业务字段判断。比如一个人年收入500万,但贷款金额只有1万,这很正常;如果年收入500万,贷款金额1000万,那说明负债水平很高,风险未必低。

2.3 类别型特征编码:按模型选方案

类别特征在贷款数据里很常见,比如贷款用途、住宅所有权状态、申请渠道、职业类型。不同编码方式适合不同模型,我列个对比:

  • 序号编码:适合有天然顺序的类别,比如信用等级A/B/C/D,但在风控里要小心,等级之间不一定等距。
  • 独热编码:适合基数低的类别特征,配合线性模型和带正则化的模型。但如果类别超过几十个,维度会爆炸。
  • 频率编码/计数编码:用类别在样本中出现的次数或频率替换原始值,树模型比较吃这套,因为高频类别通常更“主流”,风险模式更稳定。
  • 目标编码:用类别对应的目标变量均值(违约率)做替换,信息强,但极容易过拟合,必须配合交叉验证,后续实操部分会细讲。
  • WOE编码:把类别分箱后计算每个区间的WOE值,这是风控评分卡的标准做法,既适合逻辑回归,也天然具备业务解释性。

不要把某个编码当成万能药。我见过有人拿目标编码喂给LightGBM,结果验证集分数漂亮,一上线下测试就被打回原形,原因就是没做交叉编码。树模型其实可以直接处理类别,很多库内部有原生类别支持,优先级应当更高。

2.4 特征衍生:把业务逻辑翻译成特征

这部分是特征工程里信息增益最大的环节。贷款违约预测里,常用的衍生方向有四个:

第一个方向是“还款压力”类。最经典的是负债收入比:月还款额 / 月收入。如果原始特征里有循环贷款余额和信用额度,还可以构造信用卡使用率:循环余额 / 信用额度。使用率超过80%的客群,违约率明显上升,这在风控圈是共识。还有一些压力特征,比如还款额占收入比例太高,说明月光族,抗风险能力差。

第二个方向是“历史信用表现”类。比如过去两年的逾期次数占总账户数的比例:逾期笔数 / 总信用账户数。这个比例比单纯的逾期笔数更能反映信用行为习惯。还有信用账户平均账龄,账龄越长说明信用历史越稳定;最近一次开卡距现在的月数,如果连续在短时间内开很多卡,说明资金链可能紧张。

第三个方向是“查询与多头借贷”类。这类特征在许多数据集里接近禁区,因为涉及个人隐私,但在建模时确实有效。可以衍生近一个月贷款申请次数、近三个月被机构查询次数、涉及的小贷机构数量等。申请次数越多,说明越缺钱,违约风险越高。

第四个方向是“时间与年龄”类。借款人年龄与违约率常见U型关系,太年轻缺少信用记录,太老收入来源可能不稳定。工作年限越长,就业稳定性越好。在别的地方,可以构造“授信时点距离最早信用记录的时间”,衡量信用历史长度。

我一般会先把这些方向的特征都造出来,先不做太多主观筛选,后面通过分箱、IV和相关性检验来“用数据说话”。这样既能保证业务逻辑,又能避免拍脑袋。

2.5 分箱与WOE/IV:风控特征工程的灵魂

如果只保留一个必须掌握的技巧,我给分箱和WOE/IV投一票。分箱就是把连续变量切成若干区间,然后把每个区间映射成一个编码值,通常用每个区间的证据权重(WOE)来编码。

为什么分箱?理由很实在:

  • 连续特征与违约率往往不是线性关系,比如年龄,20多岁违约率偏高,35-45岁较低,60岁以上又回升。分箱后可以捕捉这种非线性。
  • 分箱能降低异常值的冲击,极端值会被并到边缘箱子里。
  • 分箱后的特征对缺失值更友好,可以把缺失单独设一个箱。
  • 风控评分卡模型要求特征分段后单调,这跟业务逻辑也好解释。

分箱方法有等距分箱(不适合右偏特征)、等频分箱(每个箱样本数大致相同,比较常用)、卡方分箱(基于统计显著性自动合并)、决策树分箱(用目标变量监督寻找切分点)。在实操中,我会先用等频分10箱,观察每个箱的违约率,再手动合并相似且不单调的相邻箱,这样兼顾效率和业务解释。

计算WOE的原理很简单:每个箱的坏样本占比除以好样本占比,再取对数。IV(信息价值)则是坏样本占比与好样本占比之差乘以WOE的累计和。IV的数值可以用来筛选特征,通常经验值是:

  • IV小于0.02:几乎无预测力,可以直接放弃。
  • 0.02到0.1:弱预测力,可以保留参与后续筛选。
  • 0.1到0.3:中等预测力,是常见的好特征。
  • 大于0.5:要警惕,可能已经过拟合,或者是用了未来信息。

这里要泼一盆冷水,IV越高越好这个观念在竞赛里容易把人带偏。超过0.5的特征要仔细检查分箱分布,往往是因为某个箱子里只有极少几个坏样本,属于噪声,并不稳定。宁可要IV中等但单调稳定的特征,也不要那种“神特征”。

3. 实操过程:特征工程完整流水线(Python)

3.1 环境准备与数据预览

这一节我用一套模拟消费金融数据来走流程。字段大概有:贷款金额、期限、利率、分期还款额、信用等级、年收入、负债收入比、住宅所有权状态、就业年限、信用卡使用率、历史逾期笔数、最近一次逾期距离月数、信用评分、循环贷款余额、总信用账户数、贷款申请目的、申请渠道、审批结果等。

先跑一下经典的环境准备:

import numpy as np import pandas as pd from sklearn.model_selection import KFold from sklearn.preprocessing import LabelEncoder pd.set_option('display.max_columns', None) pd.set_option('display.max_rows', 100) df = pd.read_csv('loan_data.csv') print(df.shape) print(df.head(8))

拿到数据第一件事不是建模,而是看字段类型、缺失率、取值分布,以及目标变量“是否违约”的占比。风控数据集的违约率通常很低,可能只有3%-10%,所以后面做目标编码和分箱时,一定要关注每个箱内的坏样本量,别一看IV很高就上头。

3.2 缺失值处理流水线

先统一算缺失率,按阈值删列,再分类型填充,最后加缺失标志:

missing_ratio = df.isnull().mean().sort_values(ascending=False) print(missing_ratio) # 删除缺失率超过80%的列 drop_cols = missing_ratio[missing_ratio > 0.8].index df = df.drop(columns=drop_cols)

填充的时候,我分两层来写。第一层,对连续特征用中位数填充;第二层,对类别特征用出现频率最高的值填充。对部分有时序含义的特征单独赋值。

num_cols = df.select_dtypes(include=['float64', 'int64']).columns cat_cols = df.select_dtypes(include=['object', 'category']).columns for col in num_cols: if df[col].isnull().any(): median_val = df[col].median() df[col] = df[col].fillna(median_val) # 加缺失标志 df[col + '_miss'] = df[col].isnull().astype(int)

有一点要提醒,计算中位数时,只能用训练集。如果是完整的数据集,应先将训练集和测试集拆分,所有fit操作都拟合在训练集上,再transform到测试集。上面的示例为了演示可读性省略了拆分,正式项目里千万别这么做。

3.3 异常值与逻辑校验

先做逻辑约束。比如贷款金额、年收入不能小于等于0;负债收入比应该在0到100%之间;信用卡使用率应该在0到1之间。符合条件的用条件筛选替换或截断:

# 年收入小于500的统一视为缺失或异常 df.loc[df['annual_income'] < 500, 'annual_income'] = np.nan df['annual_income'] = df['annual_income'].fillna(df['annual_income'].median()) # 信用卡使用率大于1,可能是数据记录错误,但也可能是额度刚被调低 # 这里按缩尾处理,截断到0~1 df['revol_util'] = df['revol_util'].clip(lower=0, upper=1) # 对有偏分布做对数变换 df['log_income'] = np.log1p(df['annual_income']) df['log_loan'] = np.log1p(df['loan_amnt'])

做log变换时我想多说一句,变换后的特征带有“倍数变化”的含义,比如收入增加一倍,log值增加0.69,这对线性模型很有帮助。树模型上不一定有直观收益,但后期如果你尝试逻辑回归评分卡,这一步就非常重要。

3.4 类别编码:目标编码的交叉验证实现

目标编码用一个例子能讲透:对“贷款申请渠道”这一个特征,直接把每个渠道的历史违约率映射为编码值。训练集和测试集都这么干,会导致模型在训练集上好像看到了“渠道风险”,但测试集的映射没有经过训练集统计,其实信息是打折的。更危险的是,如果同一个渠道在训练集里只有一两个样本,目标编码会把它们编码成0或1这种极端值,训练集上当然分得很爽,测试集直接翻车。

可靠做法是在训练集内部做K折目标编码:每次拿K-1折数据计算编码映射,应用到本折数据;测试集则用全量训练集计算映射,或者用全局违约率填充会在验证时未出现的类别。这里给出函数:

def target_encode_cv(train, col, target, n_folds=5, seed=42): kf = KFold(n_splits=n_folds, shuffle=True, random_state=seed) encoded = np.zeros(len(train)) global_mean = train[target].mean() for train_idx, val_idx in kf.split(train): fold_train = train.iloc[train_idx] fold_val = train.iloc[val_idx] # 在折内训练数据中计算每个类别的目标均值 category_mean = fold_train.groupby(col)[target].mean() val_map = fold_val[col].map(category_mean) val_map = val_map.fillna(global_mean) encoded[val_idx] = val_map return encoded

对测试集做映射时,用全量训练集的平均编码,千万不要把测试集的目标值混进来。另外,低频类别一定要先合并成“其他”,否则一个类别只有1个样本时,目标编码等于把那个样本自身的目标值背下来了。

3.5 WOE分箱与IV计算

这里我写一个简化版等频分箱、计算WOE和IV的函数,适合理解原理。真实项目里也可以用现成的库,比如scorecardpy或toad,但原理都是同一套。

def woe_iv_by_band(df, feature, target, bins=10): # 等频分箱 try: df['band'] = pd.qcut(df[feature], q=bins, duplicates='drop') except ValueError: df['band'] = pd.cut(df[feature], bins=bins, duplicates='drop') grouped = df.groupby('band', observed=False)[target].agg( total='count', bad='sum' ).reset_index() grouped['good'] = grouped['total'] - grouped['bad'] total_bad = grouped['bad'].sum() total_good = grouped['good'].sum() grouped['bad_rate'] = grouped['bad'] / grouped['total'] grouped['bad_dist'] = grouped['bad'] / total_bad grouped['good_dist'] = grouped['good'] / total_good # 避免除零 grouped['bad_dist'] = grouped['bad_dist'].replace(0, 0.00001) grouped['good_dist'] = grouped['good_dist'].replace(0, 0.00001) grouped['woe'] = np.log(grouped['bad_dist'] / grouped['good_dist']) grouped['iv'] = (grouped['bad_dist'] - grouped['good_dist']) * grouped['woe'] iv = grouped['iv'].sum() return grouped, iv

原始特征值是连续值,分箱之后,模型里其实用的是“箱号”或“WOE值”。用grouped里的区间映射到训练集,可以把原始特征替换成对应的WOE值。比如“信用评分”这个特征,分箱后每一个箱子的WOE就代表该区间的坏样本相对全体的偏离程度。

看输出表时,我一般先看两件事:一是每个箱的样本量,低于总样本2%的箱子要小心;二是坏样本率是否大体单调。年龄这类特征,坏率可能是U型,这是业务允许的,并非单调才最好。但如果某个特征分箱后WOE值在几个箱之间剧烈震荡,比如从0.8跳到-1.2再跳回0.6,说明这个特征和目标的关系不稳定,慎用。

IV算出来后,可以做一个汇总表,把所有特征的IV排名。注意,分箱数和随机种子会影响IV,所以我在筛选时不会只用IV绝对值,还会再做相关性分析。

3.6 特征筛选与最终特征集

特征筛选我用三步走。

第一步,低方差过滤。全部取值几乎不变的特征直接删掉,因为它们提供不了区分信息。

第二步,相关性去重。计算特征之间的Spearman相关系数,如果大于0.85就保留IV更高或业务解释性更强的那个。比如“年收入log”和“年收入原始值”肯定高度相关,保留log版本就行。这一步尤其重要,逻辑回归很怕多重共线性,树模型虽然不怕但它会把重要性分散给一组相关特征,影响解释性。

corr_matrix = df_with_features.corr(method='spearman') high_corr_pairs = [] for i in range(len(corr_matrix.columns)): for j in range(i+1, len(corr_matrix.columns)): if abs(corr_matrix.iloc[i, j]) > 0.85: high_corr_pairs.append( (corr_matrix.columns[i], corr_matrix.columns[j], corr_matrix.iloc[i, j]) )

第三步,结合模型重要性排序。用LightGBM默认参数跑一版,输出feature_importance,把重要性排名靠后的特征剔除。但不要只信重要度,树模型的特征重要度对有基数大、类别多的特征会产生偏好,还要回头验证一下业务上的合理性。

最终保存特征列名清单,并在模型训练前再做一遍标准化的统一封装。这样整个特征工程就形成了一条可复用的流水线。

4. 常见问题与排查技巧实录

4.1 缺失值填充后,验证集效果反而变差

有些同学做缺失值填充时,直接把整个数据集(包括验证集)一起计算中位数,这样验证集的信息已经泄漏到了填充值里。另一种可能是填充值太粗暴,比如连续特征全用零填充,导致大量样本被归到同一个值附近,模型很难区分。

正确做法是:先拆分数据,在训练集上计算统计量,再应用到验证集;每次填充之后都增加缺失标志;如果某个特征缺失率特别高,宁可删掉也别强行填。

4.2 目标编码之后线上线下差距巨大

这个问题的根因九成是数据泄漏。常见错误包括:在全量数据集上计算目标均值、同一类别在测试集出现了但在训练集没有、低频类别没有合并就直接编码。比如某渠道在训练集出现了100次,在测试集只有2次,如果用全量数据的目标均值编码,测试集中的这两个样本就会带上未来信息,导致验证时线上分差巨大。解决办法就是前面代码里的交叉目标编码,并且对测试集只使用固定映射表。

4.3 分箱后特征不单调怎么办

做评分卡时,单调性不是铁律,但业务上更容易解释。如果分箱后两边正常、中间某个箱子的违约率跳得很奇怪,通常是小样本噪声造成的。先检查这个箱子里的总样本数,如果少于50笔,把它和相邻箱子合并再看。如果合并后依然不单调,那这个特征本身可能不是单调关系,比如年龄的U型分布,这种要保留业务含义,不要强行切分。

4.4 特征IV很高,但业务上说不通

这种“灵异特征”通常暗示模型学到的不是因果规律,而是某种代理变量。比如某个渠道的客群风险特别高,但渠道本身不是风险因素,真正驱动风险的是这批客群收入低或征信记录差。当渠道策略改变后,这个特征就会突然失效。所以在高IV特征入选前,一定做个简单检查:将特征按箱拆开,看每一箱的申请数量占比在时间切片上是否稳定,如果波动巨大,说明这个特征不稳定,别急着入模。

4.5 特征工程做完了,发现训练集和测试集字段对不上

线上数据跟建模数据不一致是常态。可能是上游新增了字段,也可能是某个字段改了定义。我习惯在特征工程流程里维护一张“字段字典”,记录每个字段的原始来源、清洗逻辑和映射表。遇到新数据的时候,先跑一遍列名比对,再执行同一个转换函数。这样即使字段缺失,也能明确知道是应删除还是用默认值填充。

最后再分享一个小技巧。做完一版特征集后,一定要留一个固定随机种子,重新抽取训练集和验证集,跑同一个模型至少五次,观察AUC和KS指标的抖动。如果指标波动很大,说明特征集中有比较敏感的噪声特征,哪怕它IV很高,也要认真考虑是否去掉。我在实际项目里踩过不少次坑,每次都以为是自己找到了某个超强特征,结果换一批验证样本就不灵了,最终活下来的还是那些业务逻辑清楚、分布稳定、分箱单调的特征。特征工程这门手艺,归根结底是在“信息量”和“稳定性”之间找平衡,你把这两点把握住了,后续建模就稳了大半。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询