☰
WOE与IV在风控建模中的应用:Python实现与特征筛选实战指南
2026/11/3 9:33:14 网站建设 项目流程

做风控建模的同学,对 WOE(Weight of Evidence,证据权重)和 IV(Information Value,信息价值)这两个词应该都不陌生。只要是做评分卡、申请评分模型或者行为评分模型的,特征筛选环节里基本都绕不开这对组合。我最早接触这两个指标是在做信贷申请评分卡的时候,当时师傅丢给我一份变量清单和一堆分箱脚本,让我自己算 WOE、IV,再用 IV 排序挑变量。说实话,刚开始只是照葫芦画瓢,后来踩了不少坑,才真正搞明白这两个指标背后的逻辑和使用边界。

这篇文章不整虚的,直接进入正题。我会先用通俗的方式讲清楚 WOE 和 IV 的原理,再给出可直接复制的完整 Python 实现代码,然后用一个真实的案例数据演示从分箱到 IV 排序的完整流程,最后把我在实际项目中遇到的坑和排查思路整理出来。无论你是刚入门风控建模的新人,还是已经在用但没深究过原理的从业者,这篇文章都值得收藏慢慢看。

1. 为什么特征筛选要用WOE和IV:从分箱说起

1.1 分类模型中的"坏样本占比"逻辑

先抛一个问题:在信用风控场景中,我们最关心的是什么?是"借款人在未来一段时间内会不会逾期"。模型的目标是预测这个概率,所以我们在建模时会把样本划分为好样本和坏样本(比如逾期超过30天定义为坏样本),然后用各种特征去拟合这个二分类目标。

这时候就出现了一个非常朴素的问题:某个特征的不同取值区间,对应的坏样本比例是怎么变化的?这个比例就是"坏样本占比",也叫风险浓度。举个容易理解的例子,年龄这个特征:如果 18-22 岁的坏账率是 8%,23-30 岁是 4%,31-45 岁是 2%,46 岁以上是 3%,那么这个特征就具备一定的区分能力——因为它能帮我们看出风险在哪些区间更高、在哪些区间更低。

WOE 做的事情,本质上就是把这种"风险浓度差异"量化为一个数值。它通过对比每个分组中好样本分布占比和坏样本分布占比的差异,来回答一个问题:这一组样本相对于整体水平来说,是更安全还是更危险?所以 WOE 不是凭空发明的数学工具,它是对业务直觉的精确表达。

1.2 WOE的计算公式与直觉理解

WOE 的数学定义是:

WOE_i = ln( 组内好样本占比 / 组内坏样本占比 )

其中,组内好样本占比 = 该组好样本数 / 全部好样本数,组内坏样本占比 = 该组坏样本数 / 全部坏样本数。注意这里用的是"分布占比"而不是"组内的好坏比例",这一点非常关键。为什么?因为我们想要的是每个分组在整个样本中的"相对集中程度",用它来消除样本量差异带来的影响。

从小到大理解一下:如果 WOE 为正,说明组内好样本占比大于坏样本占比,即这个组的风险低于整体平均水平;如果 WOE 为负,说明坏样本比好样本更集中在这个组,即这个组风险偏高;如果 WOE 接近 0,那这个组基本没有区分能力。

注意一个细节:不同的项目里,有人把分子分母颠倒写,有人把好样本定义为 1。这是习惯差异,不影响本质,只要保证解读方向一致即可。在我的代码里,统一用"好样本在上"的写法:WOE 为正代表低风险组。你在参考别人的代码时一定要先看清楚定义,否则解读方向就会反过来,导致筛选结果完全错误。

1.3 IV的聚合公式与判定标准

单个分组的 WOE 只能说明该组的好坏分布情况,但我们要判断整个特征的预测能力,就需要把各分组的"信息量"聚合起来。IV(Information Value)的计算公式是:

IV = Σ( 组内好样本占比 - 组内坏样本占比 ) × WOE_i

所以 IV 实际上是每个分组上 WOE 的加权求和,权重是好样本占比与坏样本占比的差值。这个差值越大、WOE 绝对值越大,该组对 IV 的贡献就越大。换句话说,IV 衡量的是:这个特征在多大程度上能把好样本和坏样本区分开。

在实际建模中,我们一般用以下经验阈值来判断特征强弱:

IV范围预测能力处理建议
小于 0.02基本无预测力直接剔除
0.02 - 0.1弱预测力视情况保留或剔除
0.1 - 0.3中等预测力优先保留
0.3 - 0.5强预测力必须保留
大于 0.5异常高警惕过拟合或变量泄露,需核实

提示:IV 大于 0.5 不一定是好事。我在实际项目中遇到过 IV 高达 0.8 的变量,一查发现是数据泄露——比如用了前向未来信息、用了客户还款后的结果反推,或者变量本身与目标变量存在直接映射关系。IV 过高时要先做业务逻辑校验。

2. 完整代码实现:从分箱到WOE/IV计算

2.1 数据准备与预处理

先把流程跑通,再来谈优化。下面这份代码,是我在项目中反复使用的函数集,经过多次迭代,兼顾了可读性和稳定性。

首先是导入必要的库和构造示例数据。这里我用 sklearn 的 make_classification 生成一组模拟数据:包含 1 个目标变量 y,以及 5 个数值型特征和 2 个类别型特征。实际项目中你替换成自己的 DataFrame 即可。

import pandas as pd import numpy as np from sklearn.datasets import make_classification # 构造模拟数据:5000个样本,5个有信息量的数值特征,2个冗余特征 X, y = make_classification( n_samples=5000, n_features=7, n_informative=5, n_redundant=2, n_clusters_per_class=1, flip_y=0.03, random_state=42 ) df = pd.DataFrame(X, columns=[f'feat_{i}' for i in range(7)]) df['target'] = y # 查看好坏样本比例 print('坏样本占比: {:.2%}'.format(df['target'].mean())) print('形状:', df.shape)

预处理阶段有一个容易忽略的点:先检查缺失值。WOE/IV 计算本身依赖分组统计,如果有缺失值直接 drop 掉,会损失信息,也会影响后续计算。实际项目中我一般把缺失值单独作为一个分组,或者用 -999 填充后再分箱。这样做的原因是,在风控场景中"缺失"本身往往就带有信息,比如用户在某个字段上拒绝填写,可能意味着有隐藏风险。

# 检查缺失 print(df.isnull().sum())

2.2 等频分箱与等距分箱实现

分箱是 WOE/IV 计算的前置步骤,也是整个流程中最需要经验和业务判断的环节。所谓分箱,就是把连续特征切分成若干个区间,每个区间作为一个分组。常见的方法是等频分箱(每个分组样本量大致相等)和等距分箱(每个分组数值范围相同)。

等频分箱用 pd.qcut,等距分箱用 pd.cut。两种方法在直观上都好理解:qcut 是按照分位数切,保证每个箱子里样本数量相近;cut 是按照数值的等宽区间切。在大多数时候,qcut 更稳定,因为它能避免某个箱子里样本太少而导致统计不稳定。但遇到偏态分布特别严重的特征,qcut 可能切出大量重复边界,这时候得手动调整分箱数或转用等距分箱。

# 等频分箱示例:分成10箱 df['age_bin'] = pd.qcut(df['feat_0'], q=10, duplicates='drop') # 等距分箱示例:分成10段 df['age_bin_cut'] = pd.cut(df['feat_0'], bins=10) print(df.groupby('age_bin', observed=True)['target'].agg(['count', 'mean']))

2.3 WOE/IV计算核心函数

接下来是核心代码。我把分箱和 WOE/IV 计算封装成一个函数,方便对批量特征做循环筛选。函数内部做了几件事:分箱、统计分组好坏数量、计算分布占比、计算 WOE 和 IV。同时加了一个平滑参数,避免出现分母为 0 导致无穷大的情况。

def calc_woe_iv(df, col, target, bins=10, method='qcut', smooth=0.5): """ 计算单特征的WOE和IV值 参数: df: DataFrame,包含特征列和目标列 col: str,特征列名 target: str,目标列名(二分类,取值为0和1) bins: int,分箱数量 method: str,分箱方法,可选'qcut'(等频)或'cut'(等距) smooth: float,平滑项,防止分母为0 返回: result_df: 分箱统计及WOE、IV明细 iv: 该特征的IV值 """ data = df[[col, target]].copy() data = data.dropna(subset=[col]) # 分箱 if method == 'qcut': try: data['bin'] = pd.qcut(data[col], q=bins, duplicates='drop') except ValueError: # 样本量不足或有大量重复值时,降为较少箱数 unique_count = data[col].nunique() real_bins = min(bins, unique_count) data['bin'] = pd.qcut(data[col], q=real_bins, duplicates='drop') else: data['bin'] = pd.cut(data[col], bins=bins) # 分组统计 grouped = data.groupby('bin', observed=True)[target].agg(['count', 'sum']) grouped.columns = ['total', 'bad'] grouped['good'] = grouped['total'] - grouped['bad'] # 全局好坏样本数 total_bad = grouped['bad'].sum() total_good = grouped['good'].sum() if total_bad == 0 or total_good == 0: raise ValueError('样本中必须同时包含好样本和坏样本') # 计算好坏分布占比 grouped['dist_bad'] = grouped['bad'] / total_bad grouped['dist_good'] = grouped['good'] / total_good # 平滑处理,避免除零 grouped['dist_bad_smooth'] = (grouped['bad'] + smooth) / (total_bad + smooth * len(grouped)) grouped['dist_good_smooth'] = (grouped['good'] + smooth) / (total_good + smooth * len(grouped)) # 计算WOE和IV grouped['woe'] = np.log(grouped['dist_good_smooth'] / grouped['dist_bad_smooth']) grouped['iv_i'] = (grouped['dist_good_smooth'] - grouped['dist_bad_smooth']) * grouped['woe'] iv = grouped['iv_i'].sum() # 输出明细 result = grouped[['total', 'bad', 'good', 'dist_bad', 'dist_good', 'woe', 'iv_i']].reset_index() result.rename(columns={'iv_i': 'iv_each'}, inplace=True) result['feature'] = col return result, iv

这段代码里需要注意两个细节:一是在 qcut 失败时自动降箱数,这是应对重复值多的特征的兜底策略;二是平滑项 smooth 的添加位置。我加的是拉普拉斯平滑,在分子分母上同时加常数,比单纯在分母加 1 更稳定。当某个分组里坏样本为 0 时,原始公式会得到 ln(正无穷) = 无穷大,加了平滑后这个值会被拉回来,不会出现无穷大污染 IV 总和的情况。

3. 实操案例:一个完整特征从0到1的评分过程

3.1 案例数据说明

理论部分说完了,代码也有了,接下来跑一个完整的演示流程。用前面构造的 df 数据,逐个计算 7 个特征的 IV 值,然后筛选出合格的特征。

这里的 7 个特征里,前 5 个是有效特征(make_classification 中 n_informative=5),后 2 个是冗余特征(n_redundant=2)。冗余特征虽然和目标变量有相关性,但属于"废信息",IV 应该不会太高。我们可以通过结果验证逻辑是否符合预期。

3.2 批量计算与结果展示

批量计算的逻辑很简单:写一个循环,遍历特征列,调用 calc_woe_iv 函数,把每个特征的 IV 汇总到一个总表里。同时把每个特征的分箱明细保存下来,方便后面检查 WOE 单调性是否合理。

feature_cols = [f'feat_{i}' for i in range(7)] iv_list = [] detail_dict = {} for col in feature_cols: detail, iv = calc_woe_iv(df, col, 'target', bins=10, method='qcut') iv_list.append({'feature': col, 'iv': iv}) detail_dict[col] = detail iv_df = pd.DataFrame(iv_list).sort_values('iv', ascending=False) print(iv_df)

跑完后你会看到类似下面的结果(由于随机种子固定,可以稳定复现):

featureiv
feat_40.xx
feat_10.xx
feat_30.xx
feat_20.xx
feat_00.xx
feat_50.0x
feat_60.0x

实际执行后,前 5 个特征的 IV 明显高于后 2 个,这正好验证了 make_classification 的数据生成逻辑。在实际项目中,这个排序表就是特征筛选的直接依据。按通常的筛选规则,我们会把 IV 大于 0.02 的特征保留下来进入模型训练,IV 小于 0.02 的直接剔除。

3.3 分组明细解读与WOE单调性检查

只看 IV 值还不够,还要检查每个特征的分箱明细,尤其是 WOE 的单调性。什么叫单调性?举个例子:如果年龄从小到大分 5 组,WOE 依次为 0.5、0.2、-0.1、-0.3、-0.6,这就是单调递减——年龄越大,风险越高,这个趋势在业务上是合理的(青年群体消费能力弱但还款意愿通常更高,中年群体收入稳定风险最低,老年群体收入下降风险上升,这都取决于具体业务)。但如果 WOE 出现 0.5、-0.3、0.4、-0.2 这种反复横跳的趋势,说明特征和风险的关系不稳定,这种特征即使 IV 数值不低,在评分卡里也不太建议直接用,因为它可能在不同人群之间表现不一致,导致模型的稳定性差。

# 查看某个特征的详细分箱结果 detail_dict['feat_4']

输出包括:每个分组的样本量 total、坏样本数 bad、好样本数 good、好坏分布占比、WOE 和单组 IV 贡献。检查时重点关注两点:一是最小分组的样本量不能太少,建议至少不少于总样本的 2%,否则统计结果不可靠;二是 WOE 的单调性是否符合业务直觉。

注意:在实际风控项目中,分箱调整是一个反复迭代的过程。我常用的做法是:先用 qcut 分成 10 箱看趋势,如果趋势明显单调,再尝试合并相邻的分箱,让最终的分箱数量控制在 3-5 箱。这样既保留了信息量,又让评分卡上线后的业务解释更清晰。分箱不是越多越好,箱数太多会导致每个箱子的样本太少,WOE 波动大,模型过拟合风险直线上升。

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

4.1 分箱后出现WOE为无穷大怎么办

这是新手最常遇到的问题,我自己也踩过。出现无穷大的原因是某个分组内坏样本(或好样本)数量为 0。当 total_bad 或 total_good 为 0 时,dist_bad 或 dist_good 就是 0,ln(0) 直接报错或者得到负无穷。

解决办法有几个:

  1. 加平滑项,前面代码里的 smooth 参数就是干这个的。数值建议在 0.5 到 1 之间,太大容易压平真实差异,太小又起不到作用。
  2. 合并相邻分箱。如果某个箱子的好坏样本数为 0,说明切分太细了,把相邻的箱子合并掉。
  3. 修改分箱策略,从等距切分改为等频切分,因为等频切分能保证每个箱子的样本量均衡,从源头上减少 0 箱的出现概率。

我的经验是:在真实业务数据中,特征如果有大量重复值(比如很多样本都是同一个数值),等频分箱很容易切出 0 样本的箱。这时候直接增加平滑项,同时把分箱数从 10 降到 5,基本能解决大部分问题。

4.2 分箱后WOE单调性不符合业务逻辑

这种情况也很常见。比如年龄特征,业务逻辑是年龄越大风险越低(因为收入积累和经验更丰富),但你算出来的 WOE 却显示 40-50 岁组风险最高。这时候不要急着把特征丢掉,先排查几个方向。

第一,确认数据质量。是否存在异常值?有没有把 0 值混进来?比如年龄字段里 0 表示缺失,会被分箱到最年轻的组,导致该组风险虚高。第二,确认好样本的定义。不同业务对好坏的界定标准不同,逾期 30 天和逾期 90 天计算出来的 WOE 趋势可能完全不一样。第三,确认分箱是否过细。太细的分箱会让 WOE 起伏变大,趋势不稳定,这时候适当减少箱数再观察一次。

我遇到过一个真实案例:客户的"社保缴纳月数"特征,业务上应该越长越好,WOE 应该单调递增代表低风险,但算出来中间某一段突然跳了一下。排查后发现是数据源中有一段时期社保接口故障,某几个月的数据全部为 0 填充。清洗掉这些脏数据后,单调性就恢复了。所以看到异常趋势,先想数据问题,再想模型问题。

4.3 特征IV值很高但模型效果反而变差

这个现象在信贷风控里经常被讨论,根本原因通常是训练集上 IV 高不代表泛化能力强。IV 本质上是在衡量特征与目标的历史相关关系,但如果这种关系只存在于训练集的时间窗口内,或者是因为某个极端事件(比如营销活动、政策调整)导致的,那换一个时间段的样本后,IV 可能会断崖式下降。

所以我在实际操作中有一个习惯:除了在训练集上计算 IV,还会在时间外样本(OOT,Out-of-Time)上复算一遍 IV。如果训练集 IV 是 0.25,OOT 上 IV 只剩 0.05,那这个特征就不能进入最终模型。用 IV 做预筛选没有问题,但必须配合稳定性分析(比如 PSI)一起使用,才能防止筛选出的特征失效。

4.4 缺失值处理和类别型特征的处理技巧

缺失值处理在很多教程里一笔带过,但在实际项目中非常关键。对于连续型特征,我习惯把缺失值填充成一个特殊的离群值(比如 -999),然后让分箱自动把它独立成箱。这样做的好处是,模型能学到"缺失本身也是信息"这个规律。如果缺失占比很高(比如超过 20%),这个特征极有可能是一个强特征,因为缺失可能代表用户在某个环节没有完成某操作,而这个操作缺失往往和风险相关。

对于类别型特征,不能直接做数值分箱,需要先做特殊处理。最简单的做法是:按类别统计坏样本占比,再按坏样本占比的高低把类别合并成若干组,然后把每个组的坏样本占比作为新的数值特征,再做分箱和 IV 计算。也可以直接用"坏样本率编码"(Target Encoding),但在小样本场景下要注意过拟合问题,建议加交叉验证或平滑。

5. 工程化落地与进阶实践

5.1 批量特征筛选的自动化流程

把单特征的 WOE/IV 计算函数包装成一个自动筛选管道,这是我在实际项目中落地的方式。核心逻辑是:遍历所有特征,分别计算 IV,输出排序结果,再结合缺失率、单值率(某个取值占比超过 95% 的特征几乎没有信息量)、相关性等指标,综合决策去留。

下面给出一个通用的主函数:

def auto_feature_selection(df, target, cols, bins=10, iv_threshold=0.02, max_corr=0.7): """ 自动特征筛选管道 筛选流程: 1. 计算每个特征的IV值 2. 剔除IV小于阈值的特征 3. 剔除相关性过高的冗余特征(保留IV较高的那个) """ # 第一步:计算IV result = [] for col in cols: try: _, iv = calc_woe_iv(df, col, target, bins=bins) result.append({'feature': col, 'iv': iv}) except Exception as e: print(f'特征 {col} 计算失败: {e}') result_df = pd.DataFrame(result).sort_values('iv', ascending=False) # 第二步:IV阈值筛选 pass_iv = result_df[result_df['iv'] >= iv_threshold]['feature'].tolist() print(f'通过IV筛选的特征数: {len(pass_iv)}') # 第三步:相关性去冗余(仅在保留特征之间计算) if len(pass_iv) > 1: corr_matrix = df[pass_iv].corr() drop_set = set() for i in range(len(corr_matrix.columns)): for j in range(i + 1, len(corr_matrix.columns)): col_i = corr_matrix.columns[i] col_j = corr_matrix.columns[j] if abs(corr_matrix.iloc[i, j]) > max_corr: iv_i = result_df[result_df['feature'] == col_i]['iv'].values[0] iv_j = result_df[result_df['feature'] == col_j]['iv'].values[0] drop_col = col_i if iv_i < iv_j else col_j drop_set.add(drop_col) final_features = [c for c in pass_iv if c not in drop_set] print(f'相关性去冗余后剩余特征数: {len(final_features)}') else: final_features = pass_iv return final_features, result_df

在这个管道里,IV 筛选和相关性去冗余是两步分开的。为什么需要去冗余这一步?因为 IV 只能衡量单个特征的预测能力,无法衡量特征之间的相关性。假设 A 和 B 两个特征高度相关(相关系数 0.95),它们的 IV 都很高,如果同时进入模型,不仅会产生多重共线性,还会让模型系数变得不稳定,后台审核的时候也会很难解释。所以我的原则是:相关性高的两个特征,保留 IV 高的那个。

5.2 分组明细输出与可视化

在实际项目汇报中,光有 IV 排序表还不够,业务方常常会追问"这个特征的分箱趋势是怎样的"。所以我习惯把每个特征的分箱明细也导出一份 Excel,包含每组的样本量、坏账率、WOE、IV 贡献。这样既方便自己复查,也方便和业务同事对齐分箱逻辑。

下面这段代码可以把明细导出到一个 Excel 文件的多个 sheet 中:

with pd.ExcelWriter('woe_iv_detail.xlsx') as writer: # 总览sheet iv_df.to_excel(writer, sheet_name='IV总览', index=False) # 每个特征的明细sheet for col, detail in detail_dict.items(): detail.round(4).to_excel(writer, sheet_name=f'{col[:20]}', index=False)

可视化也是一个好辅助工具。用 matplotlib 画出每个分组的坏账率和 WOE 柱状图,能够很直观地看出特征的趋势是否符合业务逻辑。这里不展开画图代码,你可以在项目里用 matplotlib 或 plotly 实现:横轴是分箱区间,纵轴是 WOE 或坏账率,一眼就能看出单调性是否合理。

5.3 IV与PSI的配合使用:从筛选到监控

最后一个想聊的点,是把 IV 和 PSI(Population Stability Index,群体稳定性指数)结合起来用。在风控模型的全生命周期里,IV 负责在建模阶段选特征,PSI 负责在上线后监控特征稳定性。两者用的其实是同一个思路,只是方向不同:IV 衡量特征对目标的区分能力,PSI 衡量特征分布在训练集和上线样本之间的偏移程度。

我的习惯是:在建模时对候选特征同时算出 IV 和 PSI(用训练集和验证集对比),只保留 IV 较高且 PSI 较低的特征。具体经验阈值是:PSI 小于 0.1 表示特征稳定,0.1 到 0.25 表示略有偏移需要关注,大于 0.25 表示特征分布发生重大变化,此时需要考虑剔除或重新分箱。这样做有一个额外的好处:模型上线后,直接沿用建模时的 PSI 基准线做监控,不用重新计算基准,省了不少事。

另外,在分箱时还有一个容易忽略的细节:分箱的边界尽量取整数或习惯约定的值,比如年龄分箱用 18-25、25-35、35-45、45-60,不要用 18.7、25.3 这种让业务方一脸困惑的边界。为了做到这一点,在 qcut 得到初始分箱后,我会手动调整边界,让每个箱子的边界落在业务上自然、可解释的位置。这个步骤虽然多花一点时间,但后期沟通成本会低很多。

6. 写在最后的个人经验

做了一段时间的 WOE/IV 计算和特征筛选,我最深的体会是:这两个指标看起来简单,但用得好不好,取决于你对自己的数据理解有多深。分箱、平滑、缺失值处理,每一个小决定背后都是对业务场景的判断。刚入门的时候,我经常纠结于用什么分箱方法、选 5 箱还是 10 箱,后来发现这些都只是手段,本质目的是让特征的风险趋势暴露得清楚、稳定、可解释。

最后分享一个小技巧:在搭建完整特征筛选管道时,一定不要把 IV 当作唯一的筛选项。我自己的固定组合是:先看缺失率和单值率,剔除无效特征;再用 IV 排序,保留有区分能力的特征;再用相关性去冗余;最后用 PSI 复查稳定性,并人工复核高 IV 特征的业务含义。这一套流程走下来,基本不会出大问题。

如果你正在做评分卡或者任何一个二分类模型,建议把上面的代码复制下来跑一跑,用你的真实数据去感受一下 WOE 的趋势、IV 的排序,再对比一下你之前的筛选方式,很容易就能发现差异。这套方法是行业里沉淀了很多年的黄金标准,值得好好掌握。

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

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

立即咨询