尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Python申请评分卡实战:从WOE分箱到PSI监控全流程
简介这是一份面向金融风控与数据挖掘方向的Python申请评分卡模型实战资料适合具备一定Python与机器学习基础、希望系统掌握信贷信用评分建模流程的学习者与从业者。资源围绕申请评分卡的核心环节展开涵盖数据预处理、特征筛选、模型训练、性能评估、打分规则制定及评分卡落地应用并针对样本不平衡与模型可解释性等常见难点给出处理思路。压缩包共14个文件约9.22MB包含5个py脚本、7个pkl序列化文件、1个csv数据集及1个lr_classweight权重文件脚本覆盖逻辑回归、GBDT、DNN等多种建模方案pkl文件保存了WOE字典、分箱字典、编码字典与训练测试数据csv为原始申请数据便于直接复现与二次实验。目前已有1696人学习下载可帮助读者快速搭建从数据到评分卡的完整链路理解评分卡原理并积累可迁移的风控建模经验。1. 申请评分卡模型到底在算什么从一份逾期名单说起手里拿到一份三万行的信贷申请记录字段包括年龄、月收入、负债比、历史逾期次数、近半年查询次数还有一列label——0 表示正常还款1 表示曾经逾期超过 90 天。业务方要的不是一份报表而是一个能在新客户进件时实时吐出「通过 / 拒绝 / 人工复核」的分数。这就是申请评分卡Application Scorecard要解决的问题把历史样本里「什么样的人会坏」这件事压缩成一组权重稳定的变量和一个可解释的分数刻度。它和市面上那些端到端的黑盒模型最大的区别在于可解释性和稳定性。评分卡通常用逻辑回归做骨架配合 WOEWeight of Evidence编码和 IVInformation Value筛选最终输出的是每个变量对应的一列分数业务能拿着这张表去跟风控策略、监管口径对齐。数据集在这里的角色是地基——字段质量、坏样本定义、时间窗口切分任何一环出问题后面调参调到天亮都是白费。这篇内容面向的是手里已经有结构化申请数据、想用 Python 从零跑通一版评分卡的人新手能照着步骤复现熟手能对照参数边界检查自己的流程。2. 数据集拿到手先别急着建模字段分桶与坏样本定义2.1 申请评分卡数据集的典型字段结构一份能直接用于申请评分卡的数据集通常包含四类字段。第一类是身份与人口属性比如年龄、婚姻状态、学历、居住城市等级第二类是收入与负债比如月收入、月负债、负债收入比、房产情况第三类是征信行为比如近 6 个月查询次数、近 12 个月逾期次数、最大逾期天数、信用卡使用率第四类是标签也就是label或target定义什么算「坏」。这里有个容易被忽略的点坏样本定义不是固定的。有的机构把 M1逾期 1 到 30 天就算坏有的只认 M3逾期 90 天以上。定义越松坏样本越多模型越容易学到噪声定义越紧坏样本越少正负样本失衡越严重。常见做法是先用 M3 作为主定义同时保留 M1 作为观察窗口的辅助标签后续做稳定性对比。字段类型示例字段建模处理方式数值型连续月收入、负债比分箱后转 WOE数值型离散查询次数、逾期次数直接分箱或按业务切点类别型学历、婚姻状态合并稀有类别后转 WOE标签label0/1定义坏样本检查失衡比例2.2 用 pandas 做第一轮数据体检拿到数据集后第一步不是画图而是把缺失、异常、标签分布三件事查清楚。下面这段代码是我每次拿到新数据都会先跑的体检脚本。import pandas as pd import numpy as np # 读取数据集假设是 csv 格式 df pd.read_csv(application_scorecard.csv) # 1. 标签分布坏样本占比 bad_rate df[label].mean() print(f坏样本占比: {bad_rate:.4f}) print(df[label].value_counts()) # 2. 缺失率按列统计超过 50% 的列要警惕 missing df.isnull().mean().sort_values(ascendingFalse) print(missing[missing 0]) # 3. 数值型字段的分布边界 num_cols [age, monthly_income, debt_ratio, query_6m, overdue_12m] print(df[num_cols].describe(percentiles[0.01, 0.05, 0.5, 0.95, 0.99])) # 4. 类别型字段的取值数量 cat_cols [education, marriage, city_level] for col in cat_cols: print(col, df[col].nunique(), df[col].value_counts().head(5).to_dict())这段代码的逻辑很直接先看标签均值和计数判断正负样本是否失衡再看缺失率决定哪些列直接丢弃、哪些列需要填充然后用分位数看数值字段的极端值比如月收入 99 分位是 8 万但最大值是 500 万这种大概率是录入错误最后看类别字段的取值数量取值超过 20 个的类别字段通常需要先做稀有类别合并。参数上唯一需要根据业务调整的是缺失率阈值。我一般把 50% 作为硬线超过就丢30% 到 50% 之间的列如果业务上重要就用中位数或众数填充并加一个缺失指示列。坏样本占比低于 5% 时后续建模要考虑样本加权或过采样但评分卡场景下我更倾向保留原始分布加权重而不是直接 SMOTE因为合成样本会破坏 WOE 的单调性。2.3 坏样本定义与观察窗口的切分申请评分卡的数据集通常带时间戳比如apply_date和observe_date。观察窗口是指从申请日到观察日之间的表现期一般取 6 到 12 个月。如果观察期太短很多逾期还没暴露坏样本被低估太长则数据陈旧客群可能已经漂移。我一般会按申请月份做一次坏样本率的趋势检查df[apply_month] pd.to_datetime(df[apply_date]).dt.to_period(M) monthly_bad df.groupby(apply_month)[label].agg([mean, count]) print(monthly_bad)如果某个月的坏样本率突然跳变比如从 4% 跳到 9%先别急着建模去查那个月的进件策略是不是变了、渠道是不是换了。这种跳变往往是策略调整留下的痕迹直接混在一起训练会让模型学到不该学的东西。常见做法是截取坏样本率稳定的时间段作为建模样本把跳变前后的数据单独拿出来做跨时间验证。3. 从原始字段到 WOE 编码分箱、IV 筛选与逻辑回归骨架3.1 卡方分箱与单调性检查评分卡不直接拿原始数值进逻辑回归而是先分箱再转 WOE。分箱的好处是把非线性关系线性化同时让缺失值和异常值有地方可去。常见做法是卡方分箱Python 里可以用optbinning或自己实现简化版。下面是一个基于卡方统计量的分箱函数适合中小规模数据集。import pandas as pd import numpy as np from scipy.stats import chi2 def chi_merge_bin(df, col, target, max_bins5, min_bin_pct0.05): 卡方分箱自底向上合并直到满足箱数或最小占比 # 初始按等频切 20 个细箱 df df[[col, target]].dropna().copy() df[bin] pd.qcut(df[col], q20, duplicatesdrop) while df[bin].nunique() max_bins: bin_stats df.groupby(bin)[target].agg([count, sum]) bin_stats[bad] bin_stats[sum] bin_stats[good] bin_stats[count] - bin_stats[sum] # 计算相邻箱的卡方值 chi_list [] bins bin_stats.index.tolist() for i in range(len(bins) - 1): g1, b1 bin_stats.loc[bins[i], good], bin_stats.loc[bins[i], bad] g2, b2 bin_stats.loc[bins[i1], good], bin_stats.loc[bins[i1], bad] total g1 b1 g2 b2 if total 0: chi_list.append(0) continue expected [(g1b1)*(g1g2)/total, (g1b1)*(b1b2)/total, (g2b2)*(g1g2)/total, (g2b2)*(b1b2)/total] observed [g1, b1, g2, b2] chi_val sum((o-e)**2/e for o, e in zip(observed, expected) if e 0) chi_list.append(chi_val) # 合并卡方值最小的一对 min_idx int(np.argmin(chi_list)) left_bin, right_bin bins[min_idx], bins[min_idx1] df[bin] df[bin].cat.add_categories([f{left_bin}_{right_bin}]) df.loc[df[bin].isin([left_bin, right_bin]), bin] f{left_bin}_{right_bin} df[bin] df[bin].cat.remove_unused_categories() return df.groupby(bin)[target].agg([count, mean]).rename(columns{mean: bad_rate}) # 对月收入做分箱 result chi_merge_bin(df, monthly_income, label, max_bins5) print(result)这段代码的核心逻辑是先等频切 20 个细箱然后每次找卡方值最小的一对相邻箱合并直到箱数降到max_bins。卡方值小意味着两箱的坏样本率接近合并后信息损失最小。参数max_bins一般设 4 到 6太少会损失区分度太多则每个箱的样本量不够WOE 估计不稳定。min_bin_pct是保险丝如果某个箱样本占比低于 5%即使卡方值不是最小也应该优先合并。分完箱后必须检查单调性。如果坏样本率随箱序出现「高-低-高」的跳动说明分箱不合理要么调整切点要么把这个变量降级为类别变量处理。单调性是评分卡可解释性的底线业务看到一条上下跳的曲线会直接质疑模型逻辑。3.2 WOE 与 IV 的计算及筛选阈值WOE 的定义是ln(坏样本占比 / 好样本占比)IV 是各箱 WOE 与好坏样本占比差的乘积之和。下面这段代码在分箱结果上计算 WOE 和 IV。def calc_woe_iv(df, col, target): 计算单变量的 WOE 和 IV grouped df.groupby(col)[target].agg([count, sum]) grouped.columns [total, bad] grouped[good] grouped[total] - grouped[bad] total_bad grouped[bad].sum() total_good grouped[good].sum() # 防止除零加平滑 grouped[bad_pct] (grouped[bad] 0.5) / (total_bad 0.5 * len(grouped)) grouped[good_pct] (grouped[good] 0.5) / (total_good 0.5 * len(grouped)) grouped[woe] np.log(grouped[bad_pct] / grouped[good_pct]) grouped[iv] (grouped[bad_pct] - grouped[good_pct]) * grouped[woe] iv_total grouped[iv].sum() return grouped, iv_total # 对分箱后的字段计算 woe_table, iv calc_woe_iv(df, income_bin, label) print(fIV {iv:.4f}) print(woe_table[[total, bad, woe, iv]])IV 的筛选阈值我一般这样用低于 0.02 的变量直接丢说明区分能力太弱0.02 到 0.1 之间偏弱但如果是业务强相关变量可以保留0.1 到 0.5 是主力变量超过 0.5 要警惕可能是变量泄漏或者分箱过细导致过拟合。见过 IV 高达 1.2 的变量查下来是「历史最大逾期天数」直接编码了标签信息这种必须拿掉。WOE 计算里加 0.5 平滑是为了防止某个箱坏样本为 0 导致对数无定义。样本量小于 500 的箱WOE 波动会很大建议合并。3.3 逻辑回归训练与系数符号检查WOE 转换后所有变量都变成数值型可以直接进逻辑回归。训练时用statsmodels而不是sklearn因为前者能输出系数显著性方便判断变量是否该保留。import statsmodels.api as sm # 假设 woe_df 是已经转好 WOE 的特征矩阵label 是目标 X woe_df.drop(columns[label, apply_date]) X sm.add_constant(X) y woe_df[label] model sm.Logit(y, X).fit(disp0) print(model.summary()) # 检查系数符号WOE 编码下系数应为正 coef_df pd.DataFrame({ feature: X.columns, coef: model.params.values, pvalue: model.pvalues.values }) print(coef_df[coef_df[feature] ! const].sort_values(coef))WOE 编码有个重要性质WOE 越大表示坏样本占比越高所以逻辑回归系数理论上应该为正。如果某个变量系数为负说明 WOE 计算或分箱方向反了或者变量之间存在严重共线性。我一般会先检查 VIF超过 5 的变量考虑剔除或合并。pvalue大于 0.05 的变量如果业务上不重要就删掉重要的话考虑重新分箱。训练完成后用model.predict得到概率再通过score offset factor * ln(odds)转成标准分。常见刻度是 base 600 分、PDO 50 分意思是 odds 翻倍时分数增加 50。这个转换公式在下一章展开。4. 评分刻度转换与跨时间验证模型上线前必须过的三道关4.1 从概率到标准分的转换公式逻辑回归输出的是概率业务要的是分数。转换公式如下import numpy as np def prob_to_score(prob, base_score600, base_odds50, pdo50): 概率转标准分base_score 对应 base_oddspdo 为 odds 翻倍时的分数增量 odds prob / (1 - prob) factor pdo / np.log(2) offset base_score - factor * np.log(base_odds) score offset factor * np.log(odds) return score # 对测试集打分 test_prob model.predict(X_test) test_score prob_to_score(test_prob) print(test_score.describe())参数含义base_score是基准分base_odds是基准分对应的好坏比pdo是分数刻度。比如 base_score600、base_odds50、pdo50意思是好坏比 50:1 时分数为 600好坏比翻倍到 100:1 时分数为 650。这套刻度是行业惯例方便业务在不同模型之间做对比。转换后要检查分数分布是否合理。如果大部分样本挤在 500 到 550 之间说明模型区分度不够如果分数范围超过 300 分说明有些变量的 WOE 极端值没处理好。4.2 跨时间验证KS、AUC 与分数漂移模型在训练集上表现好不代表上线后能用。申请评分卡最怕的是客群漂移所以必须做跨时间验证。常见做法是按申请月份切分前 70% 月份做训练后 30% 做测试中间留一个月的 gap。from sklearn.metrics import roc_auc_score, roc_curve def ks_stat(y_true, y_prob): fpr, tpr, _ roc_curve(y_true, y_prob) return max(tpr - fpr) # 按时间切分 df[apply_month] pd.to_datetime(df[apply_date]).dt.to_period(M) months sorted(df[apply_month].unique()) train_months months[:int(len(months)*0.7)] test_months months[int(len(months)*0.7):] train_df df[df[apply_month].isin(train_months)] test_df df[df[apply_month].isin(test_months)] # 训练后分别在训练集和测试集上算 KS 和 AUC train_auc roc_auc_score(y_train, model.predict(X_train)) test_auc roc_auc_score(y_test, model.predict(X_test)) train_ks ks_stat(y_train, model.predict(X_train)) test_ks ks_stat(y_test, model.predict(X_test)) print(f训练集 AUC{train_auc:.4f}, KS{train_ks:.4f}) print(f测试集 AUC{test_auc:.4f}, KS{test_ks:.4f})判断标准测试集 KS 低于 0.2 说明模型区分能力不足训练集和测试集 KS 差距超过 0.1 说明过拟合或漂移严重。我见过训练 KS 0.45、测试 KS 0.18 的案例查下来是某个变量的分箱切点完全拟合了训练期的客群特征换到测试期就失效了。除了 KS 和 AUC还要看分数漂移。把训练集和测试集的分数分布画在一起如果测试集整体右移或左移超过 30 分说明客群风险水平变了需要重新校准刻度。4.3 评分卡表输出与业务对齐模型通过验证后最终交付的是一张评分卡表每个变量每个箱对应一个分数。业务拿着这张表可以手工算分也可以嵌入系统自动算分。def build_scorecard(model, woe_tables, factor, offset): 生成评分卡表每个箱的分数 -(woe * coef) * factor offset/n_features scorecard [] for feature, woe_df in woe_tables.items(): coef model.params[feature] for bin_name, row in woe_df.iterrows(): score -(row[woe] * coef) * factor scorecard.append({ feature: feature, bin: bin_name, woe: row[woe], coef: coef, score: round(score, 2) }) return pd.DataFrame(scorecard) scorecard build_scorecard(model, woe_tables, factor, offset) print(scorecard.head(20))这张表要和业务逐条过。业务可能会提出「年龄 25 岁以下不应该扣这么多分」「查询次数 6 次以上要单独标记」之类的调整意见。调整后要重新跑一遍验证确认 KS 没有大幅下降。评分卡的可解释性优势就在这里体现——业务能看懂每一分是怎么来的也就能参与调整。5. 避坑与排查申请评分卡建模中最容易翻车的 5 个地方5.1 现象WOE 单调性被破坏业务拒绝签字原因分箱时只追求 IV 最大忽略了业务逻辑。比如「月收入」分箱后坏样本率出现「低-高-低」的 U 型低收入坏账高可以理解但高收入坏账也高就反常了可能是高收入人群的负债绝对值更大。解决对违反单调性的变量先检查原始数据的异常值再把相邻箱合并直到单调。如果合并后 IV 下降超过 30%考虑把这个变量拆成两个或者改用业务切点分箱。5.2 现象训练集 KS 0.4测试集 KS 0.15原因过拟合。常见于分箱过细比如每个箱只有几十个样本、变量过多超过 15 个、或者用了未来信息比如用观察期之后的字段预测观察期内的标签。解决控制变量数量在 8 到 12 个每个箱样本不少于 5%检查每个变量的时间戳确保只用申请时点之前的信息用跨时间验证代替随机切分。5.3 现象缺失值填充后 IV 虚高原因把缺失值单独作为一个箱而缺失样本的坏样本率恰好很高或很低导致 WOE 极端。这种情况在征信查询次数缺失时很常见——缺失往往意味着没有征信记录本身就是风险信号。解决缺失值单独分箱是可以的但要检查缺失样本量。如果缺失占比超过 20%且 WOE 绝对值超过 1需要谨慎可能是缺失机制与标签相关。常见做法是保留缺失箱但在评分卡表里给一个中性分数避免过度惩罚。5.4 现象分数分布双峰业务无法设定通过率原因两个子客群的分数分布差异大比如线上进件和线下进件的客群质量不同混在一起建模导致分数分布出现两个峰。解决先做客群分层分别建模或者在模型里加入渠道变量。如果渠道变量 IV 很高说明渠道本身就是强风险因子应该单独处理。5.5 现象上线三个月后 KS 从 0.35 掉到 0.22原因客群漂移或策略调整。比如市场投放换了渠道或者审批策略放宽了进件标准。解决建立月度监控每月计算 KS 和分数分布。KS 下降超过 20% 时触发预警检查是哪个变量的 WOE 漂移最大。常见做法是每季度重新校准一次刻度每半年重新训练一次模型。6. 用 PSI 做月度监控一个能提前两周发现漂移的实用技巧模型上线不是终点。我自己的习惯是评分卡交付后第一个月就开始跑 PSIPopulation Stability Index而不是等到 KS 掉了才去查。PSI 衡量的是当前样本的分数分布和训练样本的分数分布之间的差异计算简单但对漂移的敏感度比 KS 高。def calc_psi(base_scores, curr_scores, bins10): 计算 PSIbase 为训练集分数curr 为当前样本分数 # 用训练集的分位数切点 breakpoints np.percentile(base_scores, np.linspace(0, 100, bins1)) breakpoints[0] -np.inf breakpoints[-1] np.inf base_counts np.histogram(base_scores, binsbreakpoints)[0] / len(base_scores) curr_counts np.histogram(curr_scores, binsbreakpoints)[0] / len(curr_scores) # 防止除零 base_counts np.where(base_counts 0, 0.0001, base_counts) curr_counts np.where(curr_counts 0, 0.0001, curr_counts) psi np.sum((curr_counts - base_counts) * np.log(curr_counts / base_counts)) return psi # 假设 train_score 是训练集分数monthly_score 是当月分数 psi_value calc_psi(train_score, monthly_score) print(fPSI {psi_value:.4f})PSI 的判断阈值低于 0.1 表示分布稳定0.1 到 0.25 表示有轻微漂移需要关注超过 0.25 表示显著漂移必须排查原因。我一般会把 PSI 拆到每个变量上看是哪个变量的分布变化最大。常见的是「查询次数」和「负债比」这两个变量先漂因为它们对市场环境和策略调整最敏感。这个技巧的价值在于提前量。KS 是滞后指标等 KS 掉下来的时候坏账可能已经发生了PSI 是同步指标分数分布一变就能发现。我自己的习惯是每月 1 号跑一次 PSI连续两个月超过 0.1 就启动模型复审。这套流程跑下来比等到业务投诉再回头查要从容得多。希望帮到你。本文还有配套的精品资源点击获取
RELATED

相关推荐

刚刚,Anki 用户吵翻了:新一代间隔重复算法 FSRS 到底开不开?

刚刚,Anki 用户吵翻了:新一代间隔重复算法 FSRS 到底开不开?

刚刚,Anki 用户吵翻了:新一代间隔重复算法 FSRS 到底开不开? 【免费下载链接】anki Anki is a smart spaced repetition flashcard program 项目地址: https://gitcode.com/GitHub_Trending/an/anki Anki 的牌组选项页里,有…

📅 2026/10/10 23:54:33
欧税通递表港交所,合规服务商资本化提速,欧美跨境电商服务商对接平台推荐:实测按目标市场筛选合规服务商

欧税通递表港交所,合规服务商资本化提速,欧美跨境电商服务商对接平台推荐:实测按目标市场筛选合规服务商

2026年9月3日,跨境电商合规服务平台欧税通正式向港交所递交主板上市申请,由中金公司和申万宏源香港联合保荐。欧税通累计服务超26.4万名付费用户,合规服务覆盖全球121个国家和地区,已被亚马逊、TikTok、Temu、SHEIN等九个电商平台…

📅 2026/10/10 23:49:32
LSTM多输入单输出分类预测:数据形状、滑窗切片与调参避坑指南

LSTM多输入单输出分类预测:数据形状、滑窗切片与调参避坑指南

简介:这是一份基于MATLAB的长短期记忆神经网络(LSTM)数据分类预测代码包,面向需要完成多特征输入、单输出二分类或多分类任务的科研人员、工程师与高年级学生;与普通机器学习分类器不同,它利用LSTM对序列特…

📅 2026/10/10 23:49:32
MORE NEWS

更多资讯

📰

Eclipse插件的ABAP编辑器弹窗问题:从startup扩展点到后端FM的排查与修复

做 SAP 开发环境的人,多半都遇到过这种“小事见大事”的坑:鼠标双击一个 ABAP 程序名,Eclipse 里的 ABAP 编辑器还没打开,弹窗先跳出来了。你说它影响功能吧,倒也不至于,关掉还能继续干活;但每次…

📰

CVI Word例程实战:ActiveX自动化生成测试报告

简介:CVI Word例程是一套基于LabWindows/CVI开发环境的Word文档交互示例工程,面向测试测量领域的CVI初学者,用于解决在C/C程序中调用Word应用、实现文档自动生成与编辑的需求。压缩包共含37个文件,其中9个C源文件与6个头文件构成核…

📰

JavaWeb入门:学生成绩管理系统实战搭建指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

Fiddler抓包实战:从代理配置到接口问题排查的完整指南

上周联调排一个接口问题,折腾了两个小时没头绪。接口在浏览器开发者工具里明明显示200,服务端日志也确认收到了请求,但页面就是渲染不出最新数据。后来把Fiddler抓包打开,重新走了一遍流程才发现,请求在到达服务端之前…

📰

2026人力资源SaaS权威排行:技术闭环与实战效果深度剖析

2026年人力资源SaaS系统权威排行:技术闭环与实战效果深度剖析人力资源SaaS赛道这两年已经进入了真正的深水区。前几年大家还能靠"在线简历、电子请假、工资条推送"这类基础功能混日子,到了2026年,这套逻辑彻底行不通了。我去年到今…

📰

代码生成优化实战:从模板引擎到AI辅助的全链路指南

接手过遗留系统重构的人应该都有感触:真正让人头疼的往往不是手写代码,而是那些由代码生成器批量产出的“标准化”代码。它们长得一模一样、注释齐全、命名规范,但跑起来性能平平,改起来牵一发动全身。这些年我做过不少代码生成相…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬