简介面向金融信贷风控与数据建模初学者这份Python申请评分卡资源包提供了从原始数据到最终评分卡的完整闭环。压缩包内共14个文件、大小约9.22MB包含申请样本原始数据、训练集与测试集pkl序列化文件以及逻辑回归模型、权重系数、证据权重字典、变量分箱合并字典等关键中间产物同时附有5个Python脚本覆盖逻辑回归、梯度提升树与深度神经网络三类建模方案。已有1696人浏览学习。使用者既可基于已训练好的逻辑回归模型直接对申请数据进行评分也能结合各字典文件深入理解特征分箱、证据权重转换、分数映射等技术细节省去大量数据清洗与重复建模时间。从数据预处理、特征选择到模型训练、评估与评分卡输出均有现成代码与数据支撑适合作为入门练习或信贷风控业务落地的参考基线。1. 申请评分卡模型为什么说它不只是一张打分表申请评分卡Application Scorecard是信贷风控中最经典的模型形态Python 生态让它从「银行内部的黑匣子」变成了任何一个数据分析师都能落地的东西。它的核心输出是一个整数分数用来判断一个申请人违约风险的高低业务上直接决定批不批、批多少、利率多少。你可能听过「A卡」「B卡」「C卡」的说法其中 A 卡就是申请评分卡在用户提交申请时使用。真正让我觉得这个模型值得投入的是它和机器学习模型有一个本质差别评分卡不是给出一个概率就完事它会把概率映射成一张可解释、可监控、可监管的分数表。每一分都说得清楚是哪个变量贡献的。这种特性使它至今仍是信贷、消金、融资租赁领域的硬通货。对刚入行的读者来说用 Python 跑通一条完整的申请评分卡流程等于把特征工程、逻辑回归、模型监控三块核心能力一次练透。本文就从带标注的真实信贷数据集开始走完分箱、WOE 变换、逻辑回归、概率到分数的映射全过程最后聊一聊最容易被忽略的部署与监控坑。2. 数据集选择与样本切分拿到数据后先做的三件事2.1 公开数据源与真实业务数据的差距在哪申请评分卡建模需要的是「申请时点」的数据包括申请人填写的年龄、收入、职业、居住状态以及第三方征信特征如查询次数、逾期历史、负债率等。但这类数据几乎不存在完全公开的真实版本原因很直接银行和持牌机构不会把客户级别的申请数据脱敏后放出来。常见做法是使用公开的信贷数据集某平台提供的历史借贷还款记录作为模拟项目 X来跑通流程或者用带目标变量的 UCI 类数据集做方法验证。我一般会分三层看数据第一层是「申请时点可获取」的数据。注意这不是建模时才去取的变量而是指申请人填表那一刻系统里已经有的字段。征信查询次数、历史逾期记录都属于这类而「该客户最终是否逾期」这个标签就不属于它是要预测的目标。第二层是「表现期数据」用来生成标签。比如给客户 6 个月表现期看他在第 7 个月是否出现逾期 M3。这决定了样本的观察窗口和表现窗口怎么切。第三层是「排除变量」。营销渠道码、客户编号这类字段必须删掉因为它们要么不参与预测ID要么包含了建模时不可得的信息渠道本身可能已经被风控策略干预过。选公开数据集的参考标准是有明确的二分类目标列、至少 1530 个特征、样本量超过 3 万字段类型混合着数值型和类别型。这样才够演示分箱、WOE 编码和逻辑回归整套动作。2.2 用 Python 做观察期-表现期切分的标准姿势拿到数据后第一个动作不是建模而是把样本切成训练集、验证集和时间外样本。申请评分卡和普通机器学习最大的差异在于它非常看重「时间外验证」用 2024 年 1 月到 6 月的申请样本做训练用 2024 年 7 月到 9 月的样本做验证这样能看出模型在未来的真实表现。import pandas as pd import numpy as np from datetime import datetime df pd.read_csv(credit_application.csv, parse_dates[apply_date]) # 定义表现期从申请日往后推 180 天观察是否发生逾期 90 天以上 df[perf_end] df[apply_date] pd.DateOffset(days180) df[bad] (df[due_date] - df[perf_end]).dt.days.map( lambda x: 1 if x -90 else 0 ) # 样本切分按时间而不是随机切 train df[df[apply_date] 2024-07-01].copy() test df[df[apply_date] 2024-07-01].copy() print(f训练集样本量: {len(train)}, 坏样本占比: {train[bad].mean():.4f}) print(f测试集样本量: {len(test)}, 坏样本占比: {test[bad].mean():.4f})这段逻辑里最容易出错的是标签定义。「表现期 6 个月」不是说简单地把申请日期加 180 天而是要看在这个窗口内是否发生了足够严重的逾期。严重程度的定义在业务上叫「逾期期数」M1 是逾期 30 天M3 是逾期 90 天。申请评分卡一般把 M3 定义为坏客户因为 M1 的逾期很多是技术性原因造成的把它算作坏样本会让模型学到太多噪声。参数说明perf_end是表现期截止日due_date是最后一期还款日或首逾日期两者相减后小于 -90 天视为坏客户。这里的 90 天对应 M3但不同机构的容忍度不同有的消金公司用 M260 天有的银行用 M4120 天。这个参数直接决定了坏账率一般控制在 3%10% 之间比较健康太低说明定义过严模型学不到足够样本。2.3 数据切分中的四个常见坑第一个坑随机切分。评分卡样本必须按时间切因为信贷数据天然有时序性。如果随机切分验证集和训练集来自同一时间段等于模拟了一个不存在的情景——所有客户都是同一天申请的。这样 AUC 会虚高上线后大概率翻车。第二个坑忽略样本的「排除集合」。曾经有开发同学把已经人工拒绝掉的客户也拿进训练集结果模型学到的是「某个渠道来源的客户全部是坏客户」因为那个渠道的客户被策略拒掉后没有机会产生表现期数据。这叫样本选择偏差。第三个坑坏样本太少直接跑逻辑回归。很多公开数据集坏样本率低于 2%这种不平衡会使得逻辑回归的截距项严重偏向好客户。常见做法是先做下采样让好坏比接近 1:3 到 1:5或者用权重法。但要注意下采样之后概率校准的截距要重新调整。第四个坑用全部变量做分箱。有些变量在建模时点根拿不到比如「客户在申请后的第 3 天是否点击了某链接」这种字段如果混入特征集就会造成严重的未来函数问题。排查方式是逐字段问一句这个值在申请提交的那一刻系统里是否已经存在。3. 特征分箱与 WOE 变换评分卡模型的灵魂步骤3.1 为什么要分箱而不是直接用原始值这是新手最容易问的问题。逻辑回归本身可以处理连续变量为什么评分卡要先把年龄、收入这种连续特征切成一段一段原因有三个。第一线性假设太强。收入对风险的影响不是线性的——月收入 1 万到 2 万之间风险变化不大但收入低于 5000 时风险陡增高于 5 万时风险趋于平稳。原始值进模型逻辑回归只能学一个单调的系数难以表达这种非线性关系。分箱后用 WOE 编码每一箱都获得独立的权重贡献。第二异常值和缺失值好处理。把年龄分成 [18,25), [25,35), [35,50), [50,65), 缺失单独一箱极端值的影响被限制在箱内不会出现「年龄200」这种脏数据把整个模型拉偏的情况。第三单调性约束更容易实现。业务上我们通常希望「年龄越大风险越低」或者「负债率越高风险越高」这种先验知识可以在分箱后通过合并箱来强制单调让模型结果更容易通过合规审查。WOEWeight of Evidence的计算公式是WOE ln(好客户占比 / 坏客户占比)。直观理解就是这一箱里的好客户比例相对于整体好客户比例是更高还是更低。WOE 越大代表这一箱的客户风险越低。3.2 等频分箱、卡方分箱与手动调整的代码实现import pandas as pd import numpy as np from scipy.stats import chi2_contingency def mono_bin(df, var, target, n10): 等频分箱后自动合并保证每箱好坏样本都不为0且WOE单调 df df[[var, target]].dropna() # 等频分箱先切成n段 df[bin], cut_points pd.qcut( df[var], qn, duplicatesdrop, retbinsTrue ) # 统计每箱好坏样本数 grouped df.groupby(bin)[target].agg([sum, count]) grouped.columns [bad, total] grouped[good] grouped[total] - grouped[bad] # 合并坏样本为0的箱 grouped grouped[grouped[bad] 0] # 检查单调性不单调则相邻箱合并 grouped[woe] np.log( (grouped[good] / grouped[good].sum()) / (grouped[bad] / grouped[bad].sum()) ) return grouped, cut_points # 示例对年龄特征分箱 age_bin, cuts mono_bin(train, age, bad, n10) print(age_bin[[bad, total, woe]])这段代码的思路是先用pd.qcut做等频分箱保证每箱样本量大致相等然后统计每箱坏样本数量坏样本为 0 的箱直接合并到相邻箱因为坏样本为 0 时 WOE 是无穷大会导致评分卡分数出现极端值最后检查 WOE 是否随箱号单调变化。参数说明n10是初始分箱数一般取 812 之间。太少了信息损失大太多了有些箱样本量不足WOE 不稳定。实际业务中分完箱还要看一个叫「坏样本占比」的指标——每箱坏率最好呈现单调递增或递减如果不单调说明这个变量和风险的关系不是线性趋势需要手动调整箱边界。3.3 IV 值与变量筛选哪些特征值得进模型IVInformation Value衡量的是某个变量区分好坏客户的总能力公式是每个箱的 (好占比 - 坏占比) × WOE 之和。IV 值的常用判断标准IV 范围预测能力判断 0.02几乎没有预测力不建议入模0.020.10较弱可在样本量大时尝试0.100.30中等是评分卡主力变量0.300.50较强但要检查是否数据泄露 0.50可疑极大可能是未来函数或标签穿透超过 0.5 的变量要警惕。在某次模拟项目 X 中发现「客户是否在申请后 7 天内修改了手机号」这个变量的 IV 高达 0.8看着预测力极强实际上是因为只有坏客户才会在申请后发现被骗去修改联系方式。这属于典型的「事后变量」上线后根本用不了。筛选变量的实际操作是先算所有特征的 IV把 IV 0.02 的剔除对 IV 在 0.020.1 区间的特征做相关性分析保留与强变量相关度低的那几个最后用逐步回归确认显著性。同时要控制入模变量数量评分卡一般控制在 815 个变量太多会导致分数解释困难监管审查时也难以通过。4. 用 statsmodels 拟合逻辑回归参数显著性比预测精度更重要4.1 为什么选 statsmodels 而不是 sklearn这是申请评分卡和普通机器学习流程最大的分岔点。在大多数建模任务里sklearn 的 LogisticRegression 又快又方便但评分卡建模必须看每个变量的 p 值和置信区间这是监管和业务解释的硬性要求。statsmodels 的 Logit 模块直接输出系数表、p 值、AIC/BIC省去了自己拼装的麻烦。import statsmodels.api as sm import pandas as pd # 假设 train_woe 已经过WOE编码特征列为下面这些 feature_cols [age_woe, income_woe, debt_ratio_woe, query_cnt_woe, overdue_hist_woe] X train_woe[feature_cols] y train_woe[bad] # 加截距项statsmodels默认不加 X sm.add_constant(X) # 拟合逻辑回归 logit_model sm.Logit(y, X) result logit_model.fit(dispFalse) # 输出系数汇总表 print(result.summary2())关键点看P|z|这一列如果一个变量的 p 值大于 0.05说明它在这套 WOE 编码下对风险的解释不显著常见做法是把它剔除后重跑。注意这里检验的是「WOE 变换后的特征」的显著性不是原始变量的显著性。分箱质量差的变量WOE 编码后可能不显著这时要么调箱边界要么直接放弃。另外一个指标是 VIF方差膨胀因子用来检查多重共线性。一般要求 VIF 10超过 15 的变量说明与其他变量高度相关会导致系数不稳定。实现上可以用statsmodels.stats.outliers_influence.variance_inflation_factor来算。4.2 系数方向检查风控模型的第一道质检系数方向是评分卡建模最容易被忽略的检查项。WOE 编码本身有一个特性WOE 值越大代表这一箱客户越好。所以逻辑回归中某个变量的系数如果为正说明该变量的 WOE 越高坏客户概率越高——这就矛盾了因为 WOE 高的箱应该是好客户。换一个更直接的说法如果某特征的 WOE 与坏客户率是负相关那么它在逻辑回归里的系数应该为负如果所有变量的系数符号和 WOE 的业务含义相反大概率是编码方向搞反了或者分箱时好坏的分子分母写反了。我见过最典型的翻车现场就是某训练脚本里把好客户和坏客户的占比写反了模型 AUC 依然有 0.7但系数方向全反映射出的评分卡分数越高意味着风险越大——整个评分卡等于一把反向枪。# 系数方向快速检查 coef_direction pd.DataFrame({ feature: feature_cols, coef: result.params[1:], # 去掉截距 p_value: result.pvalues[1:] }) # 预期方向好的WOE特征系数应为负WOE高 - 坏率低 print(coef_direction.sort_values(coef))参数说明result.params[1:]是去掉截距后的各变量系数pvalues是对应的显著性 p 值。如果某个系数为正回到分箱结果里看看是不是该变量的 WOE 定义方向和预想不一致——有的特征如「年龄」在低龄段是坏客户在高龄段反而变坏这时候 WOE 可能出现 U 型逻辑回归只能学到线性趋势无法表达 U 型关系。这种情况常见做法是在分箱时手动合并强制单调。5. 从概率到分数评分卡刻度映射的完整实现5.1 为什么概率要映射成分数逻辑回归输出的是违约概率范围 01业务方没法直接使用。需要把它映射成一个区间分数通常是 300850 分或者 0100 分分越高代表风险越低。映射过程由两个参数控制基准分某个特定好坏比对应的分数和翻倍分数好坏比翻一倍时分数变化的多少。标准公式是score offset factor × ln(odds)其中 odds 好客户概率 / 坏客户概率 (1-p)/p。比如设定odds 50:1 时基准分 600 分odds 翻一倍100:1时分数增加到 650 分那么可以推导出 factor 50 / ln(2) ≈ 72.13offset 600 - 72.13 × ln(50) ≈ 317.8。这两个参数一旦定下整个分数映射就固定了。5.2 Python 实现评分映射与单个变量分数表import numpy as np # 设定基准分和翻倍分 base_score 600 # odds50:1 时对应的分数 base_odds 50 # 基准好坏比 pdo 50 # odds翻倍时增加的分数 factor pdo / np.log(2) offset base_score - factor * np.log(base_odds) def prob_to_score(p): 违约概率转分数 odds (1 - p) / p return offset factor * np.log(odds) # 每个变量的每个分箱单独计算分数贡献 def calc_score_table(model, feature_cols, bins_dict): coef model.params intercept coef[const] # 基准分对应的截距贡献 base_points offset - factor * intercept score_tables {} for col in feature_cols: woe_map bins_dict[col][woe] # 每个分箱的WOE coef_val coef[col] score_tables[col] { k: round(base_points / len(feature_cols) - factor * coef_val * woe, 0) for k, woe in woe_map.items() } return score_tablesprob_to_score函数就是概率到分数的直接映射实际业务中不会拿它逐客户打分因为那样只得到一个总分无法解释分数构成。真正落地用的是calc_score_table——把截距项和每个变量的系数拆解到每一个分箱上生成一张「评分卡」。参数说明intercept是逻辑回归的截距项它会被base_points吸收进基准分里。base_points / len(feature_cols)的做法是把基准分均摊到每个变量上这样每个变量都有一个「基础分」实际命中哪个分箱就加减多少分。这张表就是业务方和监管看到的评分卡每一行都能解释某客户年龄在 35-45 岁之间这项加 12 分负债率高于 80%这项扣 35 分。5.3 分数校准的经验参数与常见误区翻倍分pdo的取值直接决定了分数分布的宽度。pdo 取 50 时好坏比每翻一倍分数增加 50 分整个分数分布大约能拉开 400600 分的量级。pdo 取 20 时分布更集中好坏客户的分差不够明显pdo 取 100 时分布太宽细微的概率波动都会导致分数剧烈跳动不利于额度策略的稳定。常见误区是直接把逻辑回归的系数当成评分卡的分数权重。逻辑回归系数是「对数几率」层面的权重分数映射还要乘以 factor不乘的话分数就乱了。另外一个高频翻车点是忘记调整截距项对应的base_points。如果拟合时用的数据集经过下采样逻辑回归的截距项是偏的需要在映射前把 intercept 调回真实好坏比水平否则所有客户的分数都会整体偏移。6. 模型验证与上线监控评分卡不是训练完就结束模型训练完之后更关键的是验证和监控。把「申请评分卡模型」真正部署到决策引擎里需要过四道关。第一道关是区分度验证。在时间外测试集上计算 AUC 和 KS。KS 统计量是评分卡领域最常用的指标定义是累积好客户比例与累积坏客户比例之差的最大值from scipy.stats import ks_2samp def ks_statistic(y_true, y_score): 计算KS值大于0.3说明区分度可用 bad y_score[y_true 1] good y_score[y_true 0] ks_value ks_2samp(bad, good).statistic return ks_value # 在时间外样本上计算KS y_test_pred result.predict(sm.add_constant(X_test_woe)) ks ks_statistic(test[bad].values, y_test_pred.values) print(f时间外KS: {ks:.4f})KS 大于 0.3 说明模型有区分能力大于 0.4 算优秀但要注意 KS 过高超过 0.6反而要警惕很可能存在数据泄露。与 AUC 相比KS 更关注「在哪个分数段区分度最大」这对设置审批阈值非常直接——KS 最大值对应的分数点就是最佳切分点。第二道关是稳定性监控。模型上线后每个月要看两个指标分数分布有没有整体漂移每个变量的分箱占比有没有变化。常用的是 PSI群体稳定性指标def calculate_psi(expected, actual, buckets10): 计算PSI小于0.1稳定0.1~0.25需观察大于0.25异常 expected_bins pd.cut(expected, buckets, labelsFalse) actual_bins pd.cut(actual, buckets, labelsFalse) exp_dist expected_bins.value_counts(normalizeTrue).sort_index() act_dist actual_bins.value_counts(normalizeTrue).sort_index() psi ((act_dist - exp_dist) * np.log(act_dist / exp_dist)).sum() return psiPSI 大于 0.25 意味着客户结构发生了明显变化这时不能直接调模型要先看是客群变了还是外部环境变了常见做法是分客群查看分数分布定位漂移来自哪里。第三道关是分数与策略的联动。评分卡上线不是把它切成「大于 600 批小于 600 拒」就完了而是要与额度、利率、催收策略联动。同样是 620 分新客户和存量客户的风险含义完全不同所以在部署时要在决策引擎里配置「分群分数」的双条件规则。第四道关是模型迭代周期管理。申请评分卡通常每 612 个月重训一次因为客群结构、宏观经济、产品策略都会变化。重训不是重新走一遍流程就行要对比新旧模型在时间外样本上的 KS 和 PSI确认新模型确实优于旧模型再切换。切换时还要做一个「影子测试」——新模型并行运行 12 个月不打实际决策只记录分数和事后表现用真实数据验证。说一个我的个人习惯评分卡模型上线前我会动手随机抽 30 个训练样本手动按评分卡打一次分再用代码算一次分两边对不上就说明映射脚本有 bug。这个动作看起来笨但曾不止一次拦截住「分箱顺序和评分表顺序不一致」导致的全表错分问题。评分卡的所有关键逻辑都写在明面上跑得通不难跑得准才见功夫。希望这篇能帮你把这条路走直一点少踩几个我在坑底待过的弯。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?