风控建模这条线,很多人一上来就调参、换模型,觉得XGBoost分数上不去就换个LightGBM,再不行就上神经网络。但做了几年贷款违约预测项目后,我越来越确定一件事:决定模型上限的永远是特征,而不是算法。尤其是金融风控场景,特征工程做不好,再强的模型也只能在垃圾数据上“精雕细琢”,结果就是训练集AUC漂亮得像造假,一上线上就翻车。
这篇文章是系列第三篇,前两篇分别整理了数据清洗和基线模型搭建的思路。这篇聚焦特征工程,结合我当时做某消费金融平台的贷款违约预测项目的真实过程,把我踩过的坑、反复验证过的构造方法,还有风控场景里最容易被忽视的时序穿越问题,一次性说透。
1. 风控特征工程为什么难:先想清楚业务逻辑再写代码
很多教程讲特征工程,上来就是pd.cut分箱、LabelEncoder编码、PolynomialFeatures做组合。这些工具本身没错,但在风控场景里,脱离业务含义造特征,等于闭着眼睛开车。金融数据跟图像、文本、商品数据最大的区别在于:每个字段背后都对应一条真实的经济行为逻辑。收入、负债、查询次数、逾期记录、申请频次,这些变量的组合方式,直接反映一个借款人的资金压力和心理状态。
1.1 从违约定义倒推特征思路
我拿到项目数据的第一件事不是跑代码,而是把“违约”的定义反复看了好几遍。这个项目的口径是:借款人在等待期结束后,首期还款日逾期超过30天,即标记为违约样本。这个定义直接决定了特征构造的方向:
- 既然关注的是首期还款能力,那么借款人的现金流稳定性就是核心,不能只盯着收入绝对值,还得看收入波动率、负债收入比。
- 既然关注30天以上的逾期,那么历史还款行为(是否存在跨期还款、笔笔逾期但都在宽限期内还清)就是重要区分变量。
- 既然要预测“未来的违约”,那么所有特征在时间上必须严格早于放款日,这一点我后面单独讲。
我见过不少团队,拿到数据后先把所有列做一遍describe(),然后写脚本批量生成几百个特征,用LightGBM的特征重要性挑Top50,整条流水线跑完不到半天。这种“无脑堆特征”的做法,在真实风控场景里会带来两个致命问题:一是特征穿越,二是过拟合噪声,前者让模型上线后断崖式失效,后者让模型的稳定性一塌糊涂。
1.2 数据可以从哪几个视角拆解
风控数据的特征构造,我更习惯拆成四个视角:
| 视角 | 关注的问题 | 典型字段/数据来源 |
|---|---|---|
| 身份与资质 | 借款人是什么样的人 | 年龄、学历、职业、所在行业 |
| 收入与负债 | 借款人有没有钱 | 月收入、负债余额、授信总额、信用卡使用率 |
| 行为与习惯 | 借款人平时怎么用钱 | 消费笔数、查询次数、还款行为、APP行为数据 |
| 时序与稳定性 | 借款人的状态在变好还是变差 | 近3个月申请次数、收入趋势、查询频次变化 |
这四个视角分别在回答“是谁”“有没有钱”“怎么花钱”“趋势如何”。特征工程就是把这些原始的、零散的、可能带噪声的字段,转化成能够稳定区分“还款”和“违约”两组人群的数字信号。
我建议刚入行的同学,在写任何特征构造代码之前,先拿一张纸,把你能想到的所有可能影响违约率的业务逻辑列出来,比如:
- 收入高不代表还款能力强,收入波动大的人反而更危险
- 查询次数多说明近期在频繁申请贷款,资金紧张
- 信用卡使用率超过80%的人,通常处于高风险区间
- 居住地变更频繁,说明稳定性差
- 收入的季节性(比如批发零售行业年底高、年初低)影响还款节奏
把这些业务逻辑沉淀下来,特征工程才有灵魂。否则就是对着数据集机械地“加列”,造出来的特征自己都解释不了。
2. 单变量特征的构造逻辑:基数、比率、离散度都要有
单变量特征不等于“把一列取出来直接用”。原始字段往往是原始形态,比如身份证号、年龄、收入金额、所在城市编码,直接丢进模型不仅没有区分度,还可能引入噪声。我在这个项目里把单变量特征分成了五类做法。
2.1 基础统计量不能只取原始值
对于连续变量,比如月收入、授信额度、消费金额,直接使用原始数值是最常见的做法,但很多字段的右偏分布会让模型很痛苦。我一般会做几组变换:
log1p对数变换,压缩长尾分布- 分位数截断(比如取99%分位封顶)
- 百分位排名,转换到0-1区间,消除量纲
以月收入为例,原始收入范围从几千到几百万都有,如果不做变换,模型很容易被极端值牵着走。截断到99%分位后,再取对数,这一列基本就平稳了。不过要注意,截断阈值必须在训练集上算好并保存,验证集和测试集沿用同一个阈值,不能各自计算。
另外我建议加几列“二次派生的基础统计量”:
- 收入的近6个月标准差(波动性)
- 消费金额的月度均值与中位数之差(极端消费倾向)
- 授信额度使用率的近期变化(当前值减近3个月均值)
这些基础统计量单独用区分度平平,但放到后面跟其他特征组合,往往非常有效。
2.2 比率特征:风控的“性价比之王”
比率特征是我在这个项目里单变量效果最好的一类。因为比率天然消除了个体规模差异,把绝对数转化为人人可比的“压力指标”。常用的比率特征包括:
- 月负债 / 月收入(负债收入比,DTI)
- 信用卡已用额度 / 信用卡总额度(使用率)
- 月还款额 / 月收入(月供收入比)
- 本笔申请金额 / 年收入
- 近3个月查询次数 / 近12个月查询次数(短期集中度)
举一个具体案例:数据集里有A和B两个人,A月入3万,负债1.5万;B月入8000,负债4000。只看负债绝对值,A是B的近4倍,模型可能把A判为高风险。但算一下负债收入比,A的DTI是0.5,B也是0.5,两个人压力水平没有本质差别,风险应该相当。这就是比率特征的意义。
实操建议:比率特征的构造要警惕分母为0的情况。月收入为0(失业或无固定收入)的样本,直接相除会出现分母为0或无穷大。我的处理方式是先对分母做np.maximum(分母, 1),再加一个布尔特征标记“该字段原始是否为0”。不要删掉这些样本,因为0收入本身就是很强的风险信号。
2.3 离散度的价值在于捕捉异常状态
有些变量的均值和中位数几乎一致,但不同个体的分布形态差异很大。比如消费金额,普通人每月几千,偶尔买个手机花个一万多,这些正常波动拉高了月度标准差;但持续的大额消费、频繁取现、夜间转账,就会让?的离散度显著偏离正常区间。
我在项目里构造过这样一个特征:近3个月信用卡消费金额的变异系数(CV),即标准差除以均值。最后这个特征的IV值排到了Top20,逻辑也很直白:消费金额波动剧烈的人,往往收入不稳定或者资金链已经出现裂痕。比起均值,“变异度”更能刻画风险。
离散度类特征还有个好处:抗缺失能力强。部分用户没有连续6个月的消费数据,均值没法算,但只要有2-3个月的数据就能估出标准差和变异系数。
3. 时间窗口聚合特征:窗口怎么选,才是这节的关键
风控数据里真正的富矿在于行为轨迹数据。借款人过去一年在平台上的每一次浏览、每一次申请、每一次还款、每一次逾期,都是时间戳级别的行为记录。怎么把这些行为聚合成特征,是特征工程里技术含量最高、也最容易犯错的地方。
3.1 窗口类型:看存量、看增量、看趋势
我习惯把时间窗口特征分成两类:
第一类是存量型,比如“截至观察时点,该用户累计借款多少笔”。这类特征反映的是用户的历史总量,窗口一般拉长到12-24个月。
第二类是动量型,比如“最近3个月查询次数”,反映的是近期活跃度和资金需求强度的变化。这类特征窗口越短,信号越敏感,也越容易受短期波动干扰。
有效做法是把两种类型交叉起来构造趋势特征,例如:
- 近3个月查询次数 / 近12个月查询次数,比值越高,说明资金需求正急剧上升
- 近6个月平均消费额 - 近1个月平均消费额,差值越大说明消费在萎缩
- 近3个月逾期次数 - 近12个月逾期次数,看坏账行为是否在近期恶化
这些“近短/近长”的比值和差值特征,在模型里的重要性往往高于单纯的短窗口计数,因为它们捕捉的是变化的方向,而方向性信息对违约预测极有价值。
3.2 窗口长度的选择:先看业务周期,再试参数
窗口长度没有统一标准,我在项目里是这样选的:
- 查询、申请类行为:窗口选1、3、6、12个月。因为资金紧张的用户通常“临时抱佛脚”,近1-3个月的查询数比6个月前能更真实地反映当前紧急程度。
- 消费类行为:窗口选1、3、6个月。消费波动跟月度周期高度相关,至少包含一个完整账单周期。
- 还款类行为:窗口选3、6、12个月。跨期还款、连续逾期这类习惯,看3个月太短,看12个月更靠谱。
窗口越多,特征宽度会爆炸。我的原则是每个行为字段最多保留4个窗口版本:短(1个月)、中(3个月)、长(12个月)加上一个“全量累计”。再多,边际收益下降,还容易引入噪声。
3.3 聚合方式和代码示范
聚合操作本身不复杂,核心是必须先按窗口切片、再基于借款人主键做分组聚合。以下是一个标准的时间窗聚合代码,我通常会在拿到行为数据后先构建这样的结构:
import pandas as pd import numpy as np from datetime import timedelta # behavior_df: 用户行为流水表,包含 user_id, behavior_time, behavior_type, amount # apply_date: 每个样本的观察时点(比如放款日),通过 pd.merge 回填到 behavior_df def generate_window_features(df, apply_dates, window_days): """ df:行为流水表(含 user_id, behavior_time, behavior_type, amount) apply_dates:user_id -> 观察时点 window_days:窗口天数 """ features = [] for days in window_days: # 限定窗口范围 temp = df.merge(apply_dates, on="user_id", how="left") temp = temp[ (temp["behavior_time"] >= temp["apply_date"] - timedelta(days=days)) & (temp["behavior_time"] <= temp["apply_date"]) ] group = temp.groupby("user_id").agg( cnt=(behavior_type="count"), sum_amt=("amount", "sum"), max_amt=("amount", "max"), mean_amt=("amount", "mean"), ) group.columns = [f"{col}_win_{days}" for col in group.columns] features.append(group) return pd.concat(features, axis=1) wind_feats = generate_window_features(behavior_df, apply_dates, [30, 90, 365])3.4 时序穿越:特征工程最隐蔽的杀手
时间窗口特征最大的坑,不是计算慢,而是数据穿越。
什么叫穿越?就是你在预测某个时间点的违约时,用了这个时间点之后才产生的行为数据。举个例子:一个人3月15日放款,5月10日逾期。如果在特征里把“截至5月底的查询次数”也放进模型,那查询次数当然包含他4月和5月的资金紧张行为,这些信息在3月15日根本不存在。模型学到的不是“预测未来”,而是“事后复盘”。
这个问题的根源通常不是故意的,而是数据表合并时没有严格对齐时间点。行为表是全量流水,观察点表也没有做时间过滤,一merge,未来信息就混进来了。
排查经验:做过风控特征的都建议留一手。我在每次构造窗口特征之后,都会写一段校验代码,把“未来30天内是否发生逾期”这个标签跟特征做时间顺序校验,确保每个样本的特征取值时间都早于标签观察时间。具体排查法可以参考:
- 取一批样本,打印观察时点和窗口特征的最大行为时间,如果最大行为时间晚于观察时点,说明穿越了。
- 把窗口特征按“观察时点+特征生成日期”做key,重建验证集,对比原始验证集的效果差异。如果差异明显,几乎可以认定有穿越。
这个坑我在项目早期踩过,当时训练集AUC高达0.85,跨时间样本上的AUC掉到0.70,排查了半天才发现是行为表时间过滤出了问题。所以再强调一次:特征数据和标签数据必须严格按时间点对齐,这个环节值得写一个专门的测试函数,永久保存复用。
4. WOE编码与IV筛选:风控人绕不开的变量筛选工具
在风控建模里,WOE(Weight of Evidence,证据权重)和IV(Information Value,信息量)几乎是“标配”工具。它们的功能有两个:一是把连续变量离散化为分箱,二是量化每个特征的预测能力,用于筛选变量。就算后面要喂进XGBoost或LightGBM,我也建议先做WOE和IV分析,因为这一步能帮你快速理解每个变量的区分逻辑。
4.1 分箱与WOE的计算过程
WOE的本质是衡量每一个分组里“坏用户占坏用户总数”和“好用户占好用户总数”的差异。计算分四步:
- 先把连续变量分成若干个区间(分箱),分箱的常用方法包括等距、等频,以及基于决策树找到切分点。
- 在每个箱内统计好用户数(good)和坏用户数(bad)。
- 计算好占比和坏占比:
好占比=good/总好用户数,坏占比=bad/总坏用户数。 - 计算WOE和IV:
woe = np.log(坏占比 / 好占比) iv = (坏占比 - 好占比) * woe对每个分箱分别求出WOE,整个变量的IV值就是所有分箱IV的加总。IV值越高,说明这个变量区分好坏用户的能力越强。常用的经验阈值是:
| IV范围 | 预测能力 |
|---|---|
| < 0.02 | 基本无预测能力 |
| 0.02 - 0.1 | 较弱 |
| 0.1 - 0.3 | 中等 |
| 0.3 - 0.5 | 较强 |
| > 0.5 | 很强,但需检查是否过度拟合或穿越 |
4.2 分箱策略:单调性比拟合程度更重要
分箱不是随便切几刀就行,风控模型里非常看重单调性。一个理想的风险变量应当满足:箱子的坏样本率随变量值单调变化。比如收入越高,坏样本率应该越低,如果某个箱子的坏样本率突然反弹,说明该区间可能混入了特殊人群,或者分箱切得不好。
我的分箱pipeline长这样:
- 先用
pd.qcut做等频分箱(保证每个箱子的样本量接近)。 - 对每个箱子计算坏样本率,手动检查单调性。如果不单调,尝试合并相邻箱子。
- 如果变量是类别型(比如行业代码),按类别合并低频项,再计算每个合并组的坏样本率。
- 对缺失值单独作为一箱处理,不填均值、不使用中位数填充。缺失本身在风控中就是信息——很多平台缺失的用户,恰恰是资质不足或申请行为异常的人。
这里特别提醒一点:分箱和WOE计算必须在训练集上完成,然后把同样的分箱规则映射到验证集和测试集。否则验证集的分布被“偷看”了,评估结果会有水分。
4.3 实操代码:一个完成WOE编码与IV计算的函数
我在项目里是这样分步实现的:
import pandas as pd import numpy as np def calc_woe_iv(df, feature, target): """ df: 包含特征和目标列 feature: 特征名 target: 目标列 """ # 对连续变量做等频分箱 df["bucket"] = pd.qcut(df[feature], q=5, duplicates="drop") grouped = df.groupby("bucket", observed=True).agg( good=(target, lambda x: (x == 0).sum()), bad=(target, lambda x: (x == 1).sum()), ) grouped["total"] = grouped["good"] + grouped["bad"] grouped["good_rate"] = grouped["good"] / grouped["good"].sum() grouped["bad_rate"] = grouped["bad"] / grouped["bad"].sum() grouped["woe"] = np.log(grouped["bad_rate"] / grouped["good_rate"]) grouped["iv"] = (grouped["bad_rate"] - grouped["good_rate"]) * grouped["woe"] return grouped, grouped["iv"].sum() # 示例:对收入字段计算 value_table, iv_value = calc_woe_iv(train_df, "monthly_income", "is_default") print(value_table) print("IV =", iv_value)用这个函数批量过一遍所有候选特征,把IV值低、分箱后坏样本率没有趋势的特征直接砍掉。这不是为了省事,而是为了稳定性——低IV特征往往充满噪声,上线后跨时间表现极不稳定。
5. 交叉特征与相关性控制:不是所有组合都叫特征工程
特征工程做到这一步,单变量的价值基本挖得差不多了。接下来做交叉特征,这个阶段特别容易失控:交叉组合的维度一旦多起来,特征数量可以轻松从几十膨胀到几千。我在项目里对此深有体会,一开始造了差不多700多个特征,后来硬生生砍到120个左右,模型效果反而更稳。
5.1 业务驱动的交叉特征,才算有效特征
交叉特征的核心不是“把所有列两两相乘”,而是**“结合业务逻辑寻找二阶组合”**。我列几个这个项目里真实有效的交叉特征:
- 查询次数 × 负债收入比:近期查询多同时负债高的人群,风险远高于单独一个维度高的人。
- 年龄 × 收入稳定性:年轻且收入波动大,说明处于职业不稳定期;中年且收入波动大,可能是经营出问题。
- 信用卡使用率 × 本笔申请金额占比:两者同时走高,说明用户现金流已经很紧,还要继续加杠杆。
- 居住稳定性 × 查询渠道:短期内频繁换居住地同时申请大量网贷入口,基本能锁定高风险人群。
这些交叉特征的内在逻辑都是“两个信号互相印证”。单独一个信号可能只是噪声,但两个独立信号同时出现,风险信号的可信度就会大幅提升。
5.2 相关性分析与冗余剔除
特征大幅增加后,下一步是控制冗余。两个强相关特征同时进入模型,会放大不稳定性,对树模型的影响小于线性模型,但仍然存在过拟合风险,而且上线后特征分布一旦漂移,相关性结构跟着变化,模型输出会偏。
我维护了一套标准的特征筛选流程:
# 1. 计算相关性矩阵,找出相关系数绝对值 > 0.7的特征对 corr_matrix = train_features.corr().abs() upper_tri = corr_matrix.where(np.triu(np.ones(corr_matrix.shape), k=1).astype(bool)) high_corr_pairs = [(upper_tri.index[i], upper_tri.columns[j]) for i in range(len(upper_tri.index)) for j in range(i + 1, len(upper_tri.columns)) if upper_tri.iloc[i, j] > 0.7] # 2. 对每个高相关特征对,保留IV值较高、和业务解释更契合的一方 def dedup_features(features, high_corr_pairs, iv_rank): drop_set = set() for f1, f2 in high_corr_pairs: if iv_rank.loc[f1] > iv_rank.loc[f2]: drop_set.add(f2) else: drop_set.add(f1) return [f for f in features if f not in drop_set]还有一个容易被忽略的细节:对类别型变量的独热编码要控制稀疏性。某个分类只有极少数样本,独热编码之后那一列几乎全是0,对模型没有贡献,还占内存。我一般会把占比低于1%的类别合并到“其他”类。
5.3 用LightGBM做第一轮粗筛
相关性分析和IV筛选之后,我还会利用LightGBM做一轮粗筛,但这轮的目的不是选“重要性TopN”,而是找出那些重要性为0或者极低的特征。具体做法是:
- 先拿全量特征训练一版LightGBM,参数可以让过拟合,比如
num_leaves设大一点。 - 输出特征重要性(
gain类型),直接把重要性为0的特征删掉。 - 对重要性极低的特征,看一眼业务逻辑,确认是否保留。
但我要提醒一句:特征重要性低不代表特征没用,有时它只是被其他更强特征覆盖了信息。所以在粗筛时,我不轻易删除重要性排名靠后但有业务逻辑支撑的特征,而是先保留,让后续的交叉验证决定去留。
6. 特征稳定性与跨时间验证:上线前绝对不能跳过的一关
整条特征工程做到这个阶段,训练集上的表现通常已经非常好看。但离真正可以上线还差最关键的一步:验证特征和模型在时间维度上的稳定性。金融场景里,客群结构、宏观环境、平台策略随时在变,特征分布稍有漂移,模型准确率就会肉眼可见地下降。
6.1 时间内外验证:别被随机切分的假象骗了
很多团队做模型评估时,用随机切分的方式分训练集和验证集,这在风控场景是很危险的。随机切分会让同一时期的样本同时出现在训练和验证里,模型“见过”验证集的大致分布,AUC自然虚高。
正确的做法是按时间切分。我当时把数据切成三个时间段:
| 数据集 | 时间范围 | 用途 |
|---|---|---|
| Train | 2022年1月 - 2022年9月 | 特征构造、分箱、模型训练 |
| Validation | 2022年10月 - 2022年12月 | 超参数调整、阈值选择 |
| Test | 2023年1月 - 2023年3月 | 最终评估 |
当我从随机切分改成时间切分后,AUC从0.82掉到0.75,这个落差才是真实模型能力的反映。这个项目如果按随机切分的指标上线,大概率会被实际表现狠狠教育一顿。
6.2 特征PSI监控:做一份可以重复使用的体检表
特征的分布稳定性可以用PSI(Population Stability Index,群体稳定性指标)来衡量。PSI衡量的是特征在两个时间段的分布差异。计算公式:
psi = sum((实际占比 - 预期占比) * ln(实际占比 / 预期占比))一般经验标准是PSI小于0.1说明特征稳定,0.1到0.25之间需要关注,大于0.25说明特征分布发生剧烈变化,需要排查原因。
我做过一份特征PSI监控表,把每个特征的训练集分箱分布作为“预期分布”,再把每个月的新数据作为“实际分布”,逐月计算PSI,这样可以在上线前就发现哪些特征在跨时间后不稳定,及时剔除或做调整。这份表的更新频率,我建议至少每个月跑一次。
6.3 特征穿越的复盘清单
最后把这几年做风控特征工程的穿越排查经验整理成一张清单,每次做时序特征都要逐项确认:
| 检查项 | 说明 |
|---|---|
| 标签时间是否晚于所有特征时间 | 最基础但也最容易出错的一点 |
| 行为数据是否按观察时点截断 | 合并前必须加时间过滤条件 |
| 分箱规则是否只在训练集上计算 | 验证集、测试集不能重新分箱 |
| 滞后类特征是否正确移位 | 比如“上月逾期次数”必须是T-1期的值 |
| 外部宏观特征是否用了未来值 | GDP、利率等指标也存在版本延后问题 |
| 去重和异常值处理是否引入穿越 | 比如用全量数据均值填充缺失,会混入未来信息 |
如果你做的模型在上线前评估都正常,上线后却莫名其妙变差,优先怀疑的就是穿越问题。回溯时间线,把特征计算日期和标签观察日期逐项对齐,绝大多数情况下都能找到答案。
最后再分享一点个人体会:特征工程不是“一步登天”的活,头两轮造特征基本都是踩坑的过程,难免会被低质量特征误导。我后来养成的习惯是,每造一批特征都先跟业务人员做一次“逻辑小评审”,确认“这个特征在业务上为什么能预测违约”,过得了业务解释这一关,再放进训练管道。这个习惯帮我砍掉了至少一半的无效特征,也让我对模型行为始终有一个清晰的心智模型。做特征工程,耐心和业务敏感度,比炫技有价值得多。