简介:这是一份面向金融风控与数据挖掘方向的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)要解决的问题:把历史样本里「什么样的人会坏」这件事,压缩成一组权重稳定的变量和一个可解释的分数刻度。
它和市面上那些端到端的黑盒模型最大的区别在于可解释性和稳定性。评分卡通常用逻辑回归做骨架,配合 WOE(Weight of Evidence)编码和 IV(Information Value)筛选,最终输出的是每个变量对应的一列分数,业务能拿着这张表去跟风控策略、监管口径对齐。数据集在这里的角色是地基——字段质量、坏样本定义、时间窗口切分,任何一环出问题,后面调参调到天亮都是白费。这篇内容面向的是手里已经有结构化申请数据、想用 Python 从零跑通一版评分卡的人,新手能照着步骤复现,熟手能对照参数边界检查自己的流程。
2. 数据集拿到手先别急着建模:字段分桶与坏样本定义
2.1 申请评分卡数据集的典型字段结构
一份能直接用于申请评分卡的数据集,通常包含四类字段。第一类是身份与人口属性,比如年龄、婚姻状态、学历、居住城市等级;第二类是收入与负债,比如月收入、月负债、负债收入比、房产情况;第三类是征信行为,比如近 6 个月查询次数、近 12 个月逾期次数、最大逾期天数、信用卡使用率;第四类是标签,也就是label或target,定义什么算「坏」。
这里有个容易被忽略的点:坏样本定义不是固定的。有的机构把 M1(逾期 1 到 30 天)就算坏,有的只认 M3+(逾期 90 天以上)。定义越松,坏样本越多,模型越容易学到噪声;定义越紧,坏样本越少,正负样本失衡越严重。常见做法是先用 M3+ 作为主定义,同时保留 M1 作为观察窗口的辅助标签,后续做稳定性对比。
| 字段类型 | 示例字段 | 建模处理方式 |
|---|---|---|
| 数值型连续 | 月收入、负债比 | 分箱后转 WOE |
| 数值型离散 | 查询次数、逾期次数 | 直接分箱或按业务切点 |
| 类别型 | 学历、婚姻状态 | 合并稀有类别后转 WOE |
| 标签 | label(0/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(ascending=False) 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_bins=5, min_bin_pct=0.05): """卡方分箱:自底向上合并,直到满足箱数或最小占比""" # 初始按等频切 20 个细箱 df = df[[col, target]].dropna().copy() df["bin"] = pd.qcut(df[col], q=20, duplicates="drop") 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[i+1], "good"], bin_stats.loc[bins[i+1], "bad"] total = g1 + b1 + g2 + b2 if total == 0: chi_list.append(0) continue expected = [(g1+b1)*(g1+g2)/total, (g1+b1)*(b1+b2)/total, (g2+b2)*(g1+g2)/total, (g2+b2)*(b1+b2)/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_idx+1] 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_bins=5) 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(f"IV = {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(disp=0) 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_score=600, base_odds=50, pdo=50): """概率转标准分:base_score 对应 base_odds,pdo 为 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_score=600、base_odds=50、pdo=50,意思是好坏比 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 做月度监控:一个能提前两周发现漂移的实用技巧
模型上线不是终点。我自己的习惯是,评分卡交付后第一个月就开始跑 PSI(Population Stability Index),而不是等到 KS 掉了才去查。PSI 衡量的是当前样本的分数分布和训练样本的分数分布之间的差异,计算简单,但对漂移的敏感度比 KS 高。
def calc_psi(base_scores, curr_scores, bins=10): """计算 PSI:base 为训练集分数,curr 为当前样本分数""" # 用训练集的分位数切点 breakpoints = np.percentile(base_scores, np.linspace(0, 100, bins+1)) breakpoints[0] = -np.inf breakpoints[-1] = np.inf base_counts = np.histogram(base_scores, bins=breakpoints)[0] / len(base_scores) curr_counts = np.histogram(curr_scores, bins=breakpoints)[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(f"PSI = {psi_value:.4f}")PSI 的判断阈值:低于 0.1 表示分布稳定;0.1 到 0.25 表示有轻微漂移,需要关注;超过 0.25 表示显著漂移,必须排查原因。我一般会把 PSI 拆到每个变量上,看是哪个变量的分布变化最大。常见的是「查询次数」和「负债比」这两个变量先漂,因为它们对市场环境和策略调整最敏感。
这个技巧的价值在于提前量。KS 是滞后指标,等 KS 掉下来的时候坏账可能已经发生了;PSI 是同步指标,分数分布一变就能发现。我自己的习惯是每月 1 号跑一次 PSI,连续两个月超过 0.1 就启动模型复审。这套流程跑下来,比等到业务投诉再回头查要从容得多。
希望帮到你。
本文还有配套的精品资源,点击获取