
上一次聊完了赛题理解和整体建模思路今天正式进入数据分析阶段。天池这场金融风控比赛数据拿到手第一步不是急着训练模型而是先把数据摸透。EDAExploratory 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(ascendingFalse) missing_pct (missing / len(train)).sort_values(ascendingFalse) 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 / ficoRangeHighFICO 评分区间上下界各约 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(normalizeTrue).plot(kindbar) plt.title(目标变量分布) plt.xticks([0, 1], [非违约, 违约], rotation0) plt.show()画出来的柱状图配合数值输出能明显感受到类别不平衡的压迫感。这说明后续建模时要用 AUC 而不是准确率作为主要评估指标同时要考虑样本不均衡的处理方法比如在 LightGBM 里设置 scale_pos_weight或者用负样本下采样的方式做训练集构建。数值特征分布我看的是几个对违约预测最关键的特征loanAmnt借款金额、interestRate利率、annualIncome年收入、dti负债收入比、ficoRangeLowFICO 下界。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 到 50 代表租房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 加 meantrain[int_rate_bin] pd.qcut(train[interestRate], 10, labelsFalse) 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), k1) sns.heatmap(corr, maskmask, cmapRdBu_r, annotFalse, vmin-1, vmax1) 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/float32describe 里 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 阶段花的功夫到底值多少钱。