☰
金融风控EDA实战:贷款违约预测的数据分析与特征工程
2026/10/16 0:25:13 网站建设 项目流程

上一次聊完了赛题理解和整体建模思路,今天正式进入数据分析阶段。天池这场金融风控比赛,数据拿到手第一步不是急着训练模型,而是先把数据摸透。EDA(Exploratory Data Analysis)在这个赛题里的地位相当关键——它直接决定你后续特征工程的方向、缺失值处理的策略,甚至影响模型选型。我把这次完整的数据分析过程整理出来,包含具体代码、图表解读和踩坑记录,给准备做贷款违约预测或者类似信贷风控项目的人一个参考。

这次的分析对象是某头部金融平台的贷款记录数据,训练集约12万条样本、47个特征,标签是用户是否违约。字段覆盖了借款金额、利率、分期数、收入水平、负债率、征信历史、居住地区等维度,还包含十几个匿名脱敏特征。整体来看,这是一份非常典型的二分类风控数据集,类别不平衡、缺失值不少、特征类型混杂,正是考验数据分析基本功的场景。

1. 数据总览与环境准备:先把“体检报告”打出来

拿到数据别急着画图,先把最基础的结构性信息看清楚。这一步我习惯用几个熟悉的 pandas 方法快速完成,基本三分钟就能对数据形成初步认知。

import pandas as pd import numpy as np import matplotlib.pyplot as plt import seaborn as sns plt.rcParams['font.sans-serif'] = ['SimHei'] plt.rcParams['axes.unicode_minus'] = False train = pd.read_csv('train.csv') test = pd.read_csv('test.csv') print('训练集形状:', train.shape) print('测试集形状:', test.shape) print('训练集列名:', train.columns.tolist())

输出结果里训练集是 120000 行 47 列,测试集是 120000 行 46 列(比训练集少一列标签 isDefault)。列名里既有 loanAmnt、interestRate 这种语义明确的字段,也有 n0 到 n14 这种完全匿名的特征,这类匿名特征在比赛数据里很常见,大概率是脱敏后的第三方征信或行为数据,处理时要格外小心。

接着用 info() 看数据类型和内存占用,用 describe() 看数值列的统计量。看到的结果里有几个值得注意的点:employmentTitle 和 employmentLength 缺失比较明显,title 字段缺失率接近 5%,n 系列匿名特征整体完整但存在少量空值。数据类型上大部分是 int64 和 float64,只有 term 这种分期数看起来是离散整数,issueDate 是字符串类型,后面要转成日期格式。

这里有一个我在实际项目中养成的习惯:读入数据后立刻检查内存。120 万行数据其实不大,但如果后面要做特征工程、内存频繁扩展,初始就把数据类型压缩好,能让整个分析流程快很多。

def reduce_mem_usage(df): for col in df.columns: col_type = df[col].dtype if col_type != 'object': c_min = df[col].min() c_max = df[col].max() if str(col_type)[:3] == 'int': if c_min > np.iinfo(np.int8).min and c_max < np.iinfo(np.int8).max: df[col] = df[col].astype(np.int8) elif c_min > np.iinfo(np.int16).min and c_max < np.iinfo(np.int16).max: df[col] = df[col].astype(np.int16) elif c_min > np.iinfo(np.int32).min and c_max < np.iinfo(np.int32).max: df[col] = df[col].astype(np.int32) else: if c_min > np.finfo(np.float16).min and c_max < np.finfo(np.float16).max: df[col] = df[col].astype(np.float16) elif c_min > np.finfo(np.float32).min and c_max < np.finfo(np.float32).max: df[col] = df[col].astype(np.float32) return df train = reduce_mem_usage(train)

压缩之后内存从原来的 80MB 左右降到了 30MB 上下,后面对数据进行 groupby、merge 都会舒服很多。这种操作在核心内存只有 8G 的笔记本上尤其重要,比赛后期特征数量变大时,养成这个习惯能帮你避开不少内存溢出的尴尬。

1.1 快速识别缺失字段和异常字段

缺失值分析是数据质量探查的重头戏。我一般会先算一列缺失比例,再决定每个字段的后续处理方式。

missing = train.isnull().sum().sort_values(ascending=False) missing_pct = (missing / len(train)).sort_values(ascending=False) missing_info = pd.DataFrame({'缺失数量': missing, '缺失比例': missing_pct}) print(missing_info[missing_info['缺失数量'] > 0])

跑完的结果显示缺失字段主要集中在 employmentTitle(职位名称,缺失约 2.1%)、employmentLength(工作年限,约 2.4%)、postCode(邮编,约 0.4%)、title(借款标题,约 4.8%),还有 ficoRangeLow / ficoRangeHigh(FICO 评分区间上下界,各约 5.2%)。整体缺失率不算高,但处理方式各不相同,这个放到后面特征工程部分细说。

异常值方面,我重点检查了 annualIncome(年收入)和 revolBal(循环贷款余额)这两个连续变量。annualIncome 的最小值只有 4000,最大值超过 600 万,明显右偏且带有长尾。revolUtil(循环额度使用率)出现了超过 100% 的值,这在信贷场景里意味着用户可能存在超额透支或数据报送口径问题。这种异常值不能直接删,先标记出来,后续可以考虑做截断或单独构造“是否异常”的布尔特征。

2. 单变量分布分析:每个字段都在讲什么故事

清洗检查做完,下面进入真正的探索性分析。单变量分布看的是每个字段自身的形态,这一步能帮你形成对数据的直觉。

2.1 目标变量与核心数值特征的分布画像

先看标签 isDefault 的分布。训练集里违约样本为 23914 条,占比 19.93%,非违约样本占比 80.07%,是一个接近 1:4 的不平衡二分类问题。对于金融风控场景,这个违约率其实相当真实,信用卡或消费贷产品的坏账率通常在 2% 到 10% 之间,部分下沉客群的坏账率会更高。比赛数据直接给到 20% 的违约率,说明业务方有意筛掉了一部分明显好客户,让模型需要真正学习风险特征。

train['isDefault'].value_counts(normalize=True).plot(kind='bar') plt.title('目标变量分布') plt.xticks([0, 1], ['非违约', '违约'], rotation=0) plt.show()

画出来的柱状图配合数值输出,能明显感受到类别不平衡的压迫感。这说明后续建模时要用 AUC 而不是准确率作为主要评估指标,同时要考虑样本不均衡的处理方法,比如在 LightGBM 里设置 scale_pos_weight,或者用负样本下采样的方式做训练集构建。

数值特征分布,我看的是几个对违约预测最关键的特征:loanAmnt(借款金额)、interestRate(利率)、annualIncome(年收入)、dti(负债收入比)、ficoRangeLow(FICO 下界)。

  • loanAmnt 分布整体在 3000 到 35000 之间波动,常见区间集中在 10000 到 20000,峰值位置在 10000 和 20000 附近,这符合消费金融产品的额度设计逻辑,产品方预置了几个固定额度档位。
  • interestRate 呈阶梯状分布,在 6% 到 24% 区间内出现多个尖峰。这是信贷产品的定价特征——不同风险等级对应不同利率档位,本质上利率已经是风险等级的一个代理变量。
  • annualIncome 右偏明显,大部分客户年收入在 20 万以下,尾部延伸到数百万。建模时如果不做处理,极端值会对树模型的分裂点产生干扰。
  • dti 集中在 0 到 30 之间,代表月负债占月收入的比例,超过 40 的客户明显递减,超过 60 的几乎没有。信贷业务通常把 dti 大于 50 视为高风险,这个逻辑在数据中也有体现。

画图的时候建议用直方图加核密度曲线的组合方式,直方图看柱状形态,核密度看平滑的分布趋势,两个一起看对分布形态的判断更准确。

2.2 类别特征的取值结构与编码思路

数据里的类别型字段不少,term(分期数)、grade(评级)、subGrade(子评级)、employmentLength(工作年限区间)、homeOwnership(房产所有权)、verificationStatus(收入验证状态)、purpose(借款用途)、initialListStatus(初始列表状态)、applicationType(个人或联合申请)等。

我逐个看了它们的取值数量。grade 从 A 到 G 共 7 个等级,subGrade 从 A1 到 G5 共 35 个等级,这两个字段和利率高度相关,属于风控模型里必须重视的强特征。homeOwnership 取值为 0 到 5,0 代表租房,1 代表按揭,2 代表自有,其他值代表其他情况。purpose 有 13 个类别,以债务合并(debt_consolidation)为主,占比超过一半。

这里有一个重要的编码建议:对 subGrade 这种有序类别特征,不要直接用 LabelEncoder 编码成 1 到 35 的整数,因为树模型虽然能处理这种数值型编码,但会把类别间的顺序关系当作线性关系处理,容易产生误导性分裂。更好的方式是保留原始字符串,让 LightGBM 的类别特征处理机制自动处理,或者将其映射为等级对应的风险评分。

train['term'].value_counts() train['grade'].value_counts().sort_index() train['homeOwnership'].value_counts()

term 字段有 36 个月和 60 个月两档,60 个月分期占比不到三成,但违约率明显更高。这一点后面做交叉分析时再看。employmentLength 虽然是字符串类型,但本质上是有序的,从“< 1 年”到“10+ 年”共 11 档,可以直接映射为 0 到 10 的整数。

3. 多变量与业务洞察:谁在驱动违约风险

单变量分析解决的是“每个字段长什么样”的问题,多变量分析解决的是“哪些字段和目标变量相关、如何相关”的问题。这是 EDA 的核心产出,也是后续特征工程的主要依据。

3.1 目标变量与关键特征的交叉分析

对所有重要特征,我做了“特征分箱后计算各箱违约率”的操作,用违约率的走势来判断特征的方向性和单调性。

以 interestRate 为例,把利率从低到高等频分成 10 箱,计算每箱的违约率,结果呈现出极强的正相关:利率最低的 10% 人群违约率约 8%,利率最高的 10% 人群违约率接近 42%。这个规律非常清晰,说明利率本身就是风险定价的产物,高利率对应高风险,模型必须充分利用这个信号。

用同样的方式看 annualIncome,收入从低到高等频分箱后,违约率呈现明显的负相关:最低收入组违约率 32%,最高收入组违约率只有 12%。收入低的人更需要借贷,也更可能还不上钱,这个逻辑在数据里完全站得住。

还看了几个征信相关的字段:

  • delinquency_2years(两年内逾期次数):大多数人为 0,但逾期次数为 1 时违约率从 18% 跳到 29%,逾期 2 次时跳到 36%,边际递增效应明显。
  • revolUtil(循环额度使用率):使用率低于 30% 的人群违约率约 15%,高于 90% 的人群违约率突破 35%,信用卡额度几乎刷爆的人,现金流压力极大。

这些交叉分析的代码不复杂,本质就是 pandas 的 groupby 加 mean:

train['int_rate_bin'] = pd.qcut(train['interestRate'], 10, labels=False) bin_default_rate = train.groupby('int_rate_bin')['isDefault'].mean() print(bin_default_rate)

3.2 相关性矩阵与共线性检查

数值特征的皮尔逊相关性矩阵也值得跑一遍,但要注意:皮尔逊相关系数只能捕捉线性关系,对树模型来说非线性关联也很重要,所以相关性矩阵只是辅助工具,不能代替分组违约率的观察。

num_cols = train.select_dtypes(include=[np.number]).columns corr = train[num_cols].corr() mask = np.triu(np.ones(corr.shape), k=1) sns.heatmap(corr, mask=mask, cmap='RdBu_r', annot=False, vmin=-1, vmax=1) plt.show()

从热力图里能看出几个明显聚集:

  • interestRate 与 grade、subGrade 高度相关(相关系数接近 0.9),这本身就是业务逻辑的体现——评级越差,利率越高。
  • installment(月还款额)与 loanAmnt 强相关,相关系数超过 0.9,因为月还款额就是根据借款金额、利率和期限计算出来的,这是机械关系而非业务洞见,做特征筛选时要注意区分。
  • annualIncome 与 revolBal 呈弱负相关,收入高的人循环贷款余额相对更低。

共线性方面,grade 和 subGrade 基本是冗余的,subGrade 信息量更细,保留 subGrade 就可以。ficoRangeLow 和 ficoRangeHigh 高度相关,保留其中一个或者取均值即可。这些判断直接服务于后续的特征筛选,能帮你减少无意义的特征。

3.3 时序维度下的数据分布观察

issueDate 是放款日期,虽然比赛没有明确说明训练集和测试集按时间切分,但观察时间分布能帮你规避时序泄漏问题。

train['issueDate'] = pd.to_datetime(train['issueDate']) train['issue_month'] = train['issueDate'].dt.to_period('M') monthly_default = train.groupby('issue_month')['isDefault'].agg(['mean', 'count'])

统计结果显示训练集的放款时间覆盖了一年半左右,每个月的样本量在 4000 到 9000 之间波动,违约率在时间维度上有小幅起伏,但没有明显的单调趋势。这个结论很重要——说明虽然存在时间效应,但短期内没有剧烈变化,直接用分类模型处理不需要过度担心趋势漂移。

不过,谨慎起见,我在后续交叉验证设计时仍然采用了按时间顺序划分的时序 K 折方案,而不是随机 K 折。原因很简单:如果某个月的数据分布发生了变化,随机 K 折会让模型在训练时提前“看到”未来信息,评估结果会偏乐观。用时序切分能更真实地反映上线后模型的表现。

4. 分析结论向特征工程的落地转化

EDA 做到这里,脑子里已经有了一张清晰的风险画像。下面把分析结论整理成具体的特征工程动作,这才是 EDA 的真正价值所在。

4.1 缺失字段的处理策略

针对每个有缺失的字段,处理方式完全不一样:

  • employmentLength 和 employmentTitle 缺失率只有 2% 左右,量不大。employmentLength 缺失可以填充为“未知”,或者单独建一个“是否缺失”的标记;employmentTitle 是高基数类别特征,直接填充众数意义不大,建议以缺失直接填充为“未知”字符串。
  • postCode 缺失率低,但邮编本身是高基数类别,建模时一般做频次编码或直接删除。我选择保留频次编码,因为地区信贷风险差异是真实存在的。
  • title 缺失率 4.8%,这个字段是借款人在申请时自己填的标题,和 purpose 含义接近但更嘈杂。缺失的可以直接填充为 purpose 字段的值,或者干脆把 title 和 purpose 合并。
  • ficoRangeLow 和 ficoRangeHigh 缺失率约 5%,FICO 评分是强特征,不能轻易丢掉。我用 dti、delinquency_2years、interestRate、subGrade 这几个相关字段做了一个简单的回归模型来预测缺失值,虽然没有达到高精度,但比直接填充均值的下游模型效果要好一些。
  • 匿名特征 n 系列的缺失,数量很少,统一填充 0 或者中位数即可。

4.2 基于分布洞察构造的新特征

从 EDA 中能直接提炼出不少新特征想法:

  • 收入与借款的匹配度:annualIncome / loanAmnt,比值衡量借款相对于收入的重负程度。这个特征从交叉分析看区分度很明显,收入低但借款高的人违约率显著偏高。
  • 利率与评级的一致性偏差:subGrade 已经内含利率水平,但可以将实际利率减去该子评级下的平均利率,得到一个“定价偏差”特征,捕捉被高估或低估风险的用户。
  • FICO 评分区间的中值:取 ficoRangeLow 和 ficoRangeHigh 的均值,作为用户的统一信用评分。
  • 历史逾期记录的综合指标:delinquency_2years、pubRec、pubRecBankruptcies 这三个字段可以加总,形成“历史负面记录总数”。
  • 申请类型与负债水平的交互:applicationType 如果是联合申请,要考虑两个人的收入负债合并计算,可以构造 appType 与 annualIncome 的交叉特征。
  • 循环使用率极端标记:revolUtil 超过 100% 时设为 1,否则为 0,捕捉超额透支的警告信号。

4.3 基于 EDA 结果的建模选型建议

从数据分布可以判断:大部分特征和目标变量之间是非线性关系,而且存在大量类别特征和缺失值,这种情况 LightGBM 或 XGBoost 这种基于直方图的树模型是首选。我计划先跑一个 LightGBM 基线,把默认参数跑通,然后再用 EDA 阶段发现的强特征组合做第二轮迭代。

另外,由于类别不平衡比例接近 1:4,评估指标我坚持用 AUC 和 KS 统计量,不参考准确率。在参数设置上,LightGBM 的 is_unbalance 参数和 scale_pos_weight 都需要试验,但不要一开始就大力度调样本权重,先让模型在原始分布下跑出基线,再逐步加权重观察验证集 AUC 的变化。

5. 踩坑记录与排查心得

数据分析阶段看着简单,实际跑下来还是有不少坑,整理成表格给后面做类似项目的人参考。

问题现象根因分析解决方案
读入数据后内存占用过大,运行耗时默认 int64/float64 类型浪费内存用 reduce_mem_usage 函数压缩为 int32/int16/float32
describe 里 annualIncome 最大值异常,达到数百万长尾分布,存在高净值客户或异常录入先保留,后续特征工程做分位数截断或取对数
revolUtil 出现超过 100% 的数值报送口径或超额透支导致不删,反手构造“是否超额使用”布尔特征
installment 与 loanAmnt 相关系数很高两个字段存在机械计算关系不做强相关去重,但在特征重要性解释时注意干扰
subGrade 直接 LabelEncoder 后树模型特征重要性失真类别有序性被当作线性关系处理保留原始字符串让 LightGBM 自动处理,或映射为评分
随机 K 折验证结果波动大数据存在时间效应,随机划分泄漏未来信息改用按时间顺序的时序验证切分
title 缺失率 4.8%,直接删掉感觉可惜高基数类别缺失,填充方式不好选结合 purpose 字段填充,或合并到 purpose 中

还有一个值得单独说的心得:相关性分析不要只看正负号,还要看业务逻辑。比如 employmentTitle 和 annualIncome 天然相关,但这不说明工作职位决定了收入,而是职位类型本身和行业相关。发现强关联特征时,先回头想想这个关联在业务上是否成立,不成立大概率是代理特征或数据噪声,需要谨慎处理。

匿名特征 n0 到 n14 的分布基本都集中在几个离散值上,这类特征在树模型里很容易被用来做细微切分,但也容易造成过拟合。我的做法是先用 LightGBM 跑一遍全特征基线,通过特征重要性判断匿名特征是否有贡献,再决定是否全量保留。有些匿名特征明显是和用户行为相关的计数器,这类特征在信贷场景里往往有强预测力,不能因为“看不懂”就直接丢掉。

6. 复盘与后续迭代思路

这次数据分析给我最大的感受是:EDA 不是一次性工作,它是贯穿全程的。第一次分析做到这里,我只能形成初步假设;等到模型跑出结果,哪些特征效果不好、哪些误差集中在一个特定客群,还需要回到数据分布里重新检查。所以在项目初期,我会有意识地把分析代码组织成模块化的函数,让后面可以随时复用、随时重跑。

后续的建模迭代里,我计划优先验证三个从 EDA 中提炼的假设:第一,利率分箱后的违约率单调性是否能在 LightGBM 分裂中被充分利用,如果不能,考虑构造利率排名特征;第二,收入和借款金额的交互特征能否显著提升区分度;第三,历史负面记录总数能否替代三个单一征信特征。每一个假设都在 EDA 里有迹可循,而不是凭空猜测,这样迭代起来才有方向。

做金融风控数据分析,有一个很深的体会:数据探索的深度,决定特征工程的上限。模型只是把你在数据里发现的规律编码成数学表达式,如果你自己都没看清数据的全貌,模型也学不到更多。这一篇的分析内容就到这里,下一步我会基于这些结论跑第一版 LightGBM 基线模型,到时候再对比特征工程前后效果,看看 EDA 阶段花的功夫到底值多少钱。

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

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

立即咨询