ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

评分卡建模中的数据预处理:分箱、WOE编码与IV筛选实战

评分卡建模中的数据预处理:分箱、WOE编码与IV筛选实战 评分卡建模学习这个系列前面两篇我分别聊了数据读取、缺失值初探、异常值清洗这些基础动作。到part.3这里我打算把重心放到真正让评分卡区别于普通机器学习模型的地方分箱、WOE编码和特征筛选。这一篇的内容我原本是打算放在后面讲特征工程时再展开但实际做项目时发现如果数据预处理阶段没有把分箱边界、WOE映射这些细节理清楚后面建模和上线阶段会反复返工代价非常大。所以单独拿出来写一篇既有原理也有可直接复用的代码思路给正在学评分卡建模、或者刚接触风控模型的朋友做参考。这篇笔记的核心关键词是“评分卡建模”和“数据预处理”但我不会只堆概念。我会从为什么评分卡不能像普通XGBoost那样“把数据扔进去就行”讲起然后给出分箱、WOE编码、IV筛选的逐步操作最后补上我踩过的几个坑。整个处理流程我尽量按一个标准评分卡项目的顺序来讲这样你照着做就能跑通一个基础版本。1. 为什么评分卡的数据预处理要单独拎出来讲1.1 评分卡本质是带约束的逻辑回归特征必须可解释很多人刚接触评分卡时会有一个困惑既然分类算法有很多为什么要用评分卡我的理解是评分卡不是追求AUC最高的模型而是追求“可控、可解释、可监管”的模型。金融风控场景里模型拒绝客户后要给拒件原因监管会问你“为什么这个额度分这么低”业务方也会要求每个评分项的扣分逻辑能讲清楚。这种诉求决定了底层的模型通常是逻辑回归且输入特征不能是一团黑盒。逻辑回归本身是线性的它对特征的约束很强特征和log(odds)之间是线性关系。可现实里像“年龄”这种变量30岁和40岁对违约率的影响并不是简单的一条直线往往是低龄段风险高、高龄段风险低、中间有个平台期。如果你直接把原始年龄丢进逻辑回归模型会强行拟合一个线性趋势不仅拟合不好非线性部分还会造成特征系数不稳定。所以评分卡必须要做一步操作把连续变量离散化也就是分箱然后再用WOE编码把每个箱子映射成一个数值。这样特征和目标之间就变成了“分段线性”而WOE值本身能反映出每个箱子的好坏分布。1.2 分箱加WOE编码让特征与目标的关系稳定分箱不是一个新鲜词很多机器学习流程里也会做但评分卡里的分箱有额外的要求。第一分箱后每个箱子内的坏样本率或者bad rate最好呈现单调趋势这样业务解释时才合理比如“收入越高风险越低”第二分箱要尽量保证每个样本量不要太少否则统计上不显著第三箱子数量不宜太多否则评分卡的评分项会非常碎不好维护。WOE编码是紧接着分箱完成的。WOE是Weight of Evidence的缩写本质上是取“某个箱子里坏客户占比/好客户占比”的对数。它的好处是让特征不再是一个简单数值而是一个携带“好坏区分能力”的标签值。比如“年龄在25到30岁”这个箱子如果坏客户占比显著高于全样本那么WOE就是正的逻辑回归在这个特征上的系数就会把分数往下拉。这套组合拳下来特征和目标是分段对齐的而且天然对缺失值、极端值有容忍度——因为它们都可以单独成箱。很多普通分类模型里要先做填补、标准化、Box-Cox变换等等在评分卡流程里反而没那么重要正确的预处理是先让特征“论箱管理”。1.3 预处理输出不是“干净数据”而是“符合业务逻辑的编码表”我见过很多新人以为数据预处理就是“删掉缺失值、去掉异常值、标准化”然后用处理完的表格去建模。但在评分卡项目里预处理的最终产物不是一张干净表而是一套编码表记录每个特征的切分点、每个箱子的WOE值、IV值以及特殊值缺失、未知的处理方式。后续所有训练、验证、测试、线上评分都要依赖这套编码表保持一致。这也是part.3最想强调的思维转变你不是在清洗数据你是在构造一个稳定的特征映射规则。映射规则一旦定了就不能因为新样本而变化否则线上线的分数就不稳定。2. 分箱连续变量离散化的三条常见路线2.1 等距分箱与等频分箱什么时候应急什么时候不能碰分箱最简单粗暴的方法是等距分箱把变量取值范围分成N段每段区间长度一样。比如年龄从18到80岁等距分成10段每段大约6.2岁。这种方法的优点是实现简单cut函数一行代码就出来缺点是没有考虑目标分布容易出现某一段样本特别多、某一段几乎没有样本的情况。比如收入变量如果有个别极高值等距分箱后大部分样本会集中在第一箱后面几个箱几乎空着这样的箱子实际上是失效的。等频分箱稍微好一点按样本数量分成N份每份样本占比接近相等。qcut做这个很方便。但它同样不考虑目标变量可能出现“一刀切”切在某些业务上很别扭的位置比如收入100001元被切到高收入组而100000元在低收入组如果恰好两组的风险差异不大这个分箱就没有业务含义。我在实际项目里等距和等频只用来做初步观察或者在变量非常多时快速筛选一遍不会把它们作为最终分箱方案。原因很简单它们都没有用上标签信息分箱结果不一定能最大化特征的风险区分能力。不过如果你手上特征有几十个想快速了解每个特征长什么样先做等频分箱并交叉统计bad rate效率很高。2.2 卡方分箱和决策树分箱让目标变量参与切分既然数据预处理已经拿到了标签评分卡的分箱就应该让标签参与进来。最常用的两种有监督分箱一个是卡方分箱一个是基于决策树的分箱。卡方分箱的核心思想是自底向上合并相邻的箱子直到相邻箱子的好/坏分布没有显著差异。具体实现上每个初始箱子只有一组好坏样本相邻箱子之间的差异用卡方统计量衡量如果差异小就合并。反复迭代直到所有相邻箱子的卡方值都超过一个阈值或者箱数达到预设值。这种方法的工程实现有点绕不过有现成的库可用我在后面会把逻辑贴出来。另一种思路更直白用单特征单节点的决策树做分箱。比如用sklearn.tree.DecisionTreeClassifier(max_depth2, min_samples_leaf0.05)去拟合特征和目标决策树选的切分点就是分箱边界。这个办法优点是切分点自动根据目标找效率高还自带单调性约束的潜力但要注意控制树的深度和叶子样本比例否则很容易过拟合尤其在小样本数据集上。我在项目里的习惯是先用决策树分箱算一版边界再用卡方分箱做合并调整最后人工检查边界的业务合理性。自动分箱的结果不一定符合业务直觉比如“年龄大于51岁风险突然上升”这种边界如果业务上解释不通我就会手动微调。2.3 分箱后马上检查单调性否则后面全是白做分箱做完别急着算WOE先看一眼每个箱子的bad rate趋势。我用一个表格举个例子年龄分箱样本量坏样本数坏样本率[18, 25)1200847.00%[25, 35)22001105.00%[35, 45)1800724.00%[45, 55)900273.00%[55, 65)500102.00%这个趋势就很好年龄越大概率越低单调下降。如果出现某个箱子的bad rate突然反弹比如45到55岁变成5.5%而35到45是4%这时候就要思考这个反弹是业务真实规律还是数据噪声。如果是真实业务规律比如中年危机导致风险上升那就可以保留U型趋势但评分卡通常更偏好单调趋势因为逻辑回归和分数制容易解释。如果并非真实规律我会尝试调整分箱边界或重新合并看看能否恢复单调。这里给你一个经验值分箱后每箱样本量建议至少占总体的5%坏样本数尽量不低于30个经验法则否则WOE值会非常不稳。我见过一个特征某个箱子只有10个人、1个坏样本算出来的WOE值高得离谱后来一检查发现那10个样本里还有两个是人为录入错误等于整个分箱都被带偏了。3. WOE编码与IV值把每个箱子的好坏比量化出来3.1 WOE公式拆解它在算什么WOE的计算公式是[ WOE_i \ln\left(\frac{\text{坏样本占全体坏样本比例}}{\text{好样本占全体好样本比例}}\right) ]这里坏样本占全体坏样本比例是指第i个箱子里坏样本数量除以整个数据集坏样本总数好样本同理。写成代码就是import numpy as np import pandas as pd def calc_woe(df, feature, target): # df: 数据框, feature: 分箱列名, target: 0/1标签列 grouped df.groupby([feature, target]).size().unstack(fill_value0) # 假设target1是坏客户 grouped[good] grouped[0] grouped[bad] grouped[1] good_total grouped[good].sum() bad_total grouped[bad].sum() grouped[good_dist] grouped[good] / good_total grouped[bad_dist] grouped[bad] / bad_total grouped[woe] np.log(grouped[bad_dist] / grouped[good_dist]) # 对分母为0的箱子做保护 grouped[woe] grouped[woe].replace([np.inf, -np.inf], 0) return grouped为什么用“占总体比例”而不是直接用坏样本率呢因为WOE本质上是对数几率比的一种分解。它把每个箱子中坏样本相对于好样本的富集程度表达出来再和全样本比较。WOE大于0表示这个箱子的坏样本比例高于好样本比例风险偏高小于0则偏安全。在评分卡里特征经过WOE编码后逻辑回归拟合的是“每个箱子WOE值”和log(odds)之间的关系。所以一个箱子能不能稳定区分好坏直接决定了该特征的预测力。注意计算WOE时一定要用训练集的好/坏分布不能把验证集或全量样本加进来这涉及到后面要讲的数据泄漏问题。3.2 IV值用于特征筛选但不能只看单变量IVInformation Value是每个箱子的WOE加权求和[ IV \sum_{i1}^{n} (\text{bad_dist}_i - \text{good_dist}_i) \times WOE_i ]IV值衡量的是特征整体对好坏的区分能力下面是行业常用经验阈值IV值范围预测能力0.02基本无预测能力0.02-0.1弱预测能力0.1-0.3中等预测能力0.3-0.5强预测能力0.5可疑需检查是否过拟合或时序穿越编程上在算WOE的同时把IV也算出来grouped[iv] (grouped[bad_dist] - grouped[good_dist]) * grouped[woe] iv_total grouped[iv].sum()实际用的时候我不会只看IV排名直接取前几个。为什么因为IV衡量的是单变量区分能力两个IV都不错但高度相关的特征比如“近3个月查询次数”和“近6个月查询次数”选进模型后会造成多重共线性导致逻辑回归系数不稳定。所以我一般会把IV排名靠前的20个特征做相关性矩阵把相关性超过0.7的挑出来再结合业务含义选一个进入候选集。这一个步骤很多人会漏掉总以为IV筛选就完事了。还有一点IV不是越高越好。如果一个特征的IV超过0.5我第一反应不是兴奋而是怀疑这个特征是不是偷看了未来比如“是否已逾期字段”风险区分能力当然高但这是未来信息不能做特征。所以在筛选前要根据业务逻辑做一遍“信息时点合规”检查确保所有特征在建模时间点都是可得的。3.3 缺失值和特殊值单独成箱不参与普通分箱在评分卡预处理里缺失值最忌讳的是直接删除或均值填充因为缺失本身可能就蕴含风险信息。比如“近6个月收入”为空的客户可能是不稳定就业或资料不完整这类客户的风险特征和收入明确低的人群是不同的。所以正确的做法是把缺失值单独作为一个箱子不参与普通分箱。具体在特征编码时我会先把缺失值标记成-1或者其他不可能的数值让它单独成箱。分箱时先对非缺失部分切分分箱完成后把缺失组独立加为一个箱子分配WOE值。同理对于像“年龄0”“额度0”这种业务上限定的特殊值也应该单独成箱而不是当成普通值参与排序。这种做法能让模型更平滑地处理分布长尾和脏数据也是评分卡天然对脏数据友好的原因之一。代码上可以这样做def woe_single_feature(df, feature, target, split_points): df df.copy() df[bin] pd.cut(df[feature], binssplit_points, rightFalse) # 缺失值单独一箱 df[bin] df[bin].cat.add_categories([missing]) df.loc[df[feature].isna(), bin] missing # 特殊值单独一箱 df.loc[df[feature] 0, bin] missing # 或用special woe_df calc_woe(df, bin, target) return df[bin], woe_df这个思路在数据预处理阶段就要定下来否则到了建模阶段再去补WOE会污染整个分析流程。4. 防止数据泄漏训练集、验证集、未来样本的预处理一致性4.1 最典型的错误先用全量数据定分箱边界再切分我在线下评审代码时看到最多的错误就是先对整个数据集统一做分箱再切成训练集和测试集。比如全量数据有10万样本先用这10万算出年龄分箱边界然后把前7万当训练集、后3万当测试集。这么做的结果是测试集的“真实感”被严重破坏因为它已经参与了边界确定测试集的预测效果会被高估。正确的流程是先切分数据集然后把训练集拿出来单独确定每个特征的切分点、WOE值和IV值再将这些映射关系原封不动地应用到验证集和测试集上。我在项目里一般把所有分箱信息保存成一个Json或者数据库表{ age: { split_points: [18, 25, 35, 45, 55, 65], woe_map: { [18, 25): 0.43, [25, 35): 0.19, [35, 45): -0.05, [45, 55): -0.30, [55, 65): -0.61, missing: 0.08 }, iv: 0.23 } }后续对验证集做WOE编码时就按这个json里的split_points和woe_map去查。这么做既避免了数据泄漏也保证了不同批次数据之间的一致性。4.2 时间外样本OOT的预处理必须用训练集分箱结果在评分卡项目中除了随机切分出一个测试集一般还会拉一个时间段更晚的OOT样本做跨期验证。OOT样本的最大价值是验证模型的时间稳定性。但如果对OOT样本重新做分箱、重新算WOE那OOT就变成了“应该再被训练一次的数据”时间验证的意义就没了。正确做法是用训练集训练出的分箱边界和WOE映射直接映射到OOT样本上。每个OOT样本落到哪个箱子就直接取训练集对应箱子的WOE值。如果某个OOT样本的取值落在训练集分箱范围之外比如线上新客年龄55岁但训练集最大年龄只有50岁这就要设置“越界”处理逻辑。我的处理方式是把越界值并入最相邻的边界箱子并单独标记一个flag让模型监控后续这批样本的风险表现。这个越界处理同样要提前在预处理阶段写明不能等到上线了才临时决定。4.3 线上新样本的编码表存储与更新策略评分卡模型的预处理不只发生在建模时上线后的每一次预测也都要走同样的编码逻辑。如果训练时用Python写了一个里面藏着分箱逻辑的函数上线时再用Java手写一遍两边代码一旦不一致线上评分就完全乱套。所以我在工程落地时一定会做一件事把预处理变成一个基于配置表的通用模块。训练时把每个特征的处理方式保存成一个配置文件线上服务启动时加载这份配置解析规则后执行分箱和WOE映射。这样线上和线下共用同一套逻辑不会因为代码重写而引入偏差。配置更新也要有版本管理一旦调整了分箱切分点就需要重新走一遍训练、验证和OOT评估不能只在线上悄悄改配置。5. 把预处理固化成Pipeline从代码到落地5.1 一个最小可运行的预处理函数结构为了让上面这套流程不只是一堆理论我给一个可执行的最小代码骨架。先建一个评分卡预处理的类核心方法是fit和transform和sklearn的接口风格保持一致。import pandas as pd import numpy as np from scipy.stats import chi2_contingency class ScorecardPreprocessor: def __init__(self): self.split_points {} self.woe_map {} self.iv_map {} self.features [] def fit(self, df, features, target, n_bins5): # 训练阶段对每个特征执行分箱、WOE、IV计算并保存 self.features features for feat in features: df_bin, woe_df, split_points self._fit_single_feature( df, feat, target, n_bins ) self.split_points[feat] split_points self.woe_map[feat] woe_df.to_dict()[woe] self.iv_map[feat] woe_df[iv].sum() return self def transform(self, df): # 应用阶段用训练好的分箱边界和WOE映射来编码新数据 df df.copy() for feat in self.features: bins pd.cut(df[feat], binsself.split_points[feat], rightFalse) # 缺省、越界处理 bins bins.cat.add_categories([missing, other]) bins[pd.isna(df[feat])] missing # 其他category不匹配的由cat.codes转-1再替换 encoded bins.map(self.woe_map[feat]).fillna(0) df[f{feat}_woe] encoded return df这只是一个非常简化的版本但结构已经够了。核心思想是fit阶段保存一切映射关系transform阶段只查表绝不重新计算分箱边界。5.2 分箱映射表的设计与版本管理实际项目的特征数量可能是几十个甚至上百个每个特征有一张映射表版本管理就变得很重要。我建议至少包含三个部分特征名、分箱边界、每个箱子的WOE值。另外加一个create_time和version字段每次重新训练都要带上新版本方便回溯。举个例子versionfeaturesplit_pointswoe_mapivv1.0age[18,25,35,45,55,65]{[18,25):0.43,[25,35):0.19}0.23v1.0income[0,3000,6000,10000,20000,50000]{[0,3000):0.62,[3000,6000):0.3}0.35v1.1age[18,28,38,48,60]{[18,28):0.38,[28,38):0.21}0.24这样做还有一个好处当模型上线后发现某个特征的分布偏移时可以直接比较当前线上分布和训练集映射表里的分布快速定位到变化最大的箱子给监控系统输出指标。5.3 新样本上线时的一致性检查清单我把新样本上线前要检查的点整理成一个清单当作团队内部评审的必查项新样本的特征中是否有落在训练集分箱范围外的值如果有是否已经走“越界入邻箱”的逻辑缺失值是否按照训练时的方式单独成箱还是被错误填充成了其他值每个特征的WOE映射是否加载了最新版本训练代码和线上代码是否共用同一份映射配置如果使用了时间切分验证OOT样本是否也通过同一套transform完成编码逻辑回归模型的入模特征是否和预处理后输出的*_woe列一一对应这些听着像废话但如果踩过线上评测和训练集评测差距突然拉大的坑就会明白每条都值真金白银。6. 我在实际项目里踩过的坑希望你别再踩6.1 分箱后坏样本为0导致WOE无穷大第一次自己写WOE函数时我没处理分母为0的情况。有一个特征“是否有法院被执行记录”分箱之后“是”的箱子只有20个样本而且全部是坏客户。坏样本占比80%好样本占比0%算log(0% / something)直接得到inf。放进逻辑回归后模型直接学崩了评分结果出现了预料之外的极端分数。后来我的处理方式是对分母为0的箱子做“加一个小数”的平滑处理比如给好样本数加0.5坏样本数加0.5再算分布占比和WOE。这其实就是统计学里的Laplace平滑思想。也可以用业界常见的做法如果某个箱子样本量过小且好坏完全分离干脆把这个箱子合并到相邻箱子或者把这个特征降级处理。记住WOE不是越极端越好极端往往意味着过拟合信号。6.2 特征在训练集和OOT上的WOE方向相反还有一个更大的坑某特征在训练集上IV很高比如“近3个月申请贷款次数”分箱后趋势是次数越多风险越高WOE单调递增。但到了OOT样本上这个趋势居然反转了申请次数越多反而风险越低。这通常是宏观经济环境变化或者业务准入策略变化造成的。我当时没有第一时间用OOT的分箱效果去复盘差点把它选进最终模型。后来我养成了一个习惯在特征筛选环节不光看训练集IV还要看该特征在OOT上的PSIPopulation Stability Index和WOE趋势是否保持一致。如果某个特征在训练集和OOT上的方向翻转我会直接丢掉它因为它会让模型在不同时间里左右摇摆对评分卡的稳定性伤害太大。哪怕它训练集IV再高稳定性不够就是风险。6.3 对缺失值做中位数填充后WOE完全失真刚开始做评分卡时我延续了普通机器学习的习惯对缺失值做中位数填充然后直接分箱。结果发现缺失值和正常值被混在一起缺失值本应体现的高风险信号被“平均化”掉了导致这个特征在人口统计之外的区分能力大幅下降。后来我才意识到在评分卡场景里填缺失值的首要目标不是“不报错”而是“保留缺失本身的信息”。我现在的习惯是先做缺失率统计如果某个特征缺失率超过30%我会单独创建一个是否为缺失的0/1变量再把缺失作为单独分箱。如果缺失率低于5%单独成箱后样本量不够我会保守地把它并入好坏比最接近的全样本箱或者视业务情况决定是否填充。最后再分享一个小技巧处理分箱和WOE时不要同时操作大量特征先做3到5个核心特征完整跑一遍“分箱—WOE—IV—逻辑回归—评分刻度”的流程感受一下整条链路怎么串起来。等你熟悉了这个节奏再扩展到几十个特征就不容易乱了。评分卡建模最大的成本不是算法而是预处理阶段的耐心和细心。数据里埋着什么样的风险信号预处理环节能不能把这些信号稳定地表达出来直接决定最后那张评分卡能不能在真实场景里站得住脚。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进