news 2026/10/10 17:43:46

Python申请评分卡模型实战:从数据集准备到分数校准全流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python申请评分卡模型实战:从数据集准备到分数校准全流程

简介:本资源面向金融风控与数据挖掘方向的学习者,提供一套用Python实现申请评分卡模型的完整数据集与配套代码,帮助理解从原始申请数据到信用评分的全流程。压缩包共14个文件,约9.22MB,包含1个application.csv原始数据集、5个Python脚本(覆盖A_Card主流程、测试、GBDT与DNN建模)、7个pkl序列化文件(存放训练/测试数据、WOE字典、分箱字典、编码字典及逻辑回归模型)以及1个LR_classweight权重文件,便于直接复现数据预处理、特征选择、模型训练与评估、打分规则创建等环节。目前已有1696人学习下载,适合希望掌握评分卡原理、练习sklearn建模与分箱编码技巧的初中级数据从业者,也可作为信贷风控项目的参考方案。

1. 申请评分卡模型到底在算什么:从一份逾期名单说起

手里拿到一份三万行的信贷申请记录,字段包括年龄、月收入、负债比、历史逾期次数、近半年查询次数,还有一列label——0 表示正常还款,1 表示逾期超过 90 天。业务方要的不是一份报表,而是一个能在新客户进件时实时吐出 0 到 1000 分的打分函数,分数越高越安全,低于某个阈值直接拒件。这就是申请评分卡模型(Application Scorecard)要解决的事,而python的申请评分卡模型 数据集这个组合,本质是在问:用 Python 把一份带标签的申请数据,训练成一张可解释、可上线、可监控的评分卡,数据集该怎么准备、模型该怎么搭、分数该怎么映射。

它适合三类人:做风控策略但想自己跑通建模全流程的从业者、手里有业务数据想验证评分卡可行性的数据同学、以及被要求「用 Python 出一版评分卡」但不确定从哪下手的工程师。不适合指望调个库就出分的人——评分卡的坑大多不在模型本身,而在数据切分、WOE 分箱和分数校准这三段。

2. 数据集准备:申请评分卡的数据长什么样、怎么切

2.1 申请评分卡数据集的字段结构与标签定义

一份能直接用于申请评分卡的数据集,通常不是单一文件,而是「申请主表 + 行为衍生表」的组合。主表一行一个申请件,包含申请时点可获取的信息:身份属性(年龄、婚姻、学历)、收入负债(月收入、月负债、负债收入比)、申请行为(申请金额、期限、渠道)、征信查询(近 3 个月查询次数、近 6 个月查询次数)。行为衍生表则是从历史还款记录里算出来的,比如近 12 个月最大逾期天数、历史最长逾期期数、当前未结清贷款笔数。

标签列一般叫label或target,取值 0/1,1 代表「坏」。这里有个容易被忽略的点:坏的定义必须和业务口径一致。有的机构把 M1(逾期 1 期)就算坏,有的要 M3+(逾期 90 天以上)才算坏。口径不同,同一份数据训出来的分数分布完全不同。我一般会在数据字典里明确写一行:label=1 定义为观察点后 6 个月内出现 90 天以上逾期。

观察点(observation point)和表现期(performance window)是申请评分卡数据集的两个时间锚点。观察点是你采集申请信息的时点,表现期是观察点之后用来判定好坏的时间长度。常见做法是观察点后 6 到 12 个月。如果表现期太短,很多坏样本还没暴露,标签会偏乐观;太长则数据新鲜度下降,模型上线后衰减快。

提示:如果数据集里没有明确的时间列,先别急着建模。没有申请时间就无法做时间外样本验证,评分卡上线后的稳定性无从谈起。

2.2 用 Python 做时间外切分而不是随机切分

申请评分卡最忌讳随机切分训练集和测试集。随机切分会让同一时期的样本同时出现在训练和测试里,模型「见过」那个时期的客群特征,评估结果虚高。正确做法是按申请时间切:用较早时间段做训练,较晚时间段做验证和测试。

import pandas as pd import numpy as np # 假设 df 已加载,包含 apply_date 列和 label 列 df['apply_date'] = pd.to_datetime(df['apply_date']) # 按时间排序 df = df.sort_values('apply_date').reset_index(drop=True) # 取 70% 时间点作为训练集截止,15% 验证,15% 测试 n = len(df) train_end = int(n * 0.7) valid_end = int(n * 0.85) train = df.iloc[:train_end].copy() valid = df.iloc[train_end:valid_end].copy() test = df.iloc[valid_end:].copy() print(f"训练集 {train.shape},坏样本率 {train['label'].mean():.4f}") print(f"验证集 {valid.shape},坏样本率 {valid['label'].mean():.4f}") print(f"测试集 {test.shape},坏样本率 {test['label'].mean():.4f}")

这段代码的逻辑是按申请时间排序后顺序切分,保证训练集的时间早于验证集,验证集早于测试集。参数上,70/15/15 是常见比例,但如果数据量小,可以改成 60/20/20。切完后一定要打印三段的坏样本率,如果测试集坏样本率比训练集高出一倍以上,说明客群随时间发生了明显迁移,模型上线后需要更频繁地监控。

切分之后还要检查一件事:训练集和测试集的特征分布是否一致。常用做法是对每个数值特征做 PSI(Population Stability Index)计算,PSI 大于 0.25 说明分布差异较大,这个特征在时间外样本上可能不稳定。

def calc_psi(base, compare, bins=10): # 基于 base 分箱,统计 compare 的分布偏移 breakpoints = np.percentile(base, np.linspace(0, 100, bins + 1)) breakpoints = np.unique(breakpoints) base_counts = np.histogram(base, bins=breakpoints)[0] / len(base) compare_counts = np.histogram(compare, bins=breakpoints)[0] / len(compare) # 避免除零 base_counts = np.where(base_counts == 0, 1e-6, base_counts) compare_counts = np.where(compare_counts == 0, 1e-6, compare_counts) psi = np.sum((compare_counts - base_counts) * np.log(compare_counts / base_counts)) return psi for col in ['age', 'monthly_income', 'debt_ratio', 'query_3m']: psi_val = calc_psi(train[col].dropna(), test[col].dropna()) print(f"{col} 的 PSI = {psi_val:.4f}")

PSI 的计算逻辑是:以训练集的分位点为切分点,分别统计训练集和测试集落在每个区间的比例,再用比例差乘以对数比求和。参数bins=10是经验值,数据量大时可以加到 20。PSI 小于 0.1 说明稳定,0.1 到 0.25 需要关注,大于 0.25 建议在建模时对这个特征做特殊处理,比如只保留粗分箱或直接剔除。

3. 从原始字段到 WOE 分箱:评分卡可解释性的来源

3.1 为什么申请评分卡必须做 WOE 分箱

申请评分卡和普通二分类模型最大的区别是可解释性要求。业务方要能回答「这个客户为什么被拒」,监管也要能审查模型是否使用了歧视性变量。WOE(Weight of Evidence)分箱把连续变量切成若干段,每段用一个 WOE 值替代原始值,WOE 的计算公式是:

WOE = ln(该箱坏样本占比 / 该箱好样本占比)

WOE 越大,说明这个箱里的坏样本比例越高,风险越大。IV(Information Value)则是各箱 WOE 的加权和,用来衡量一个变量的整体区分能力。IV 小于 0.02 基本没用,0.02 到 0.1 弱,0.1 到 0.3 中等,0.3 到 0.5 强,大于 0.5 要警惕过拟合。

分箱的另一个好处是处理缺失值和异常值。月收入缺失可以单独成一箱,月收入 999999 这种异常值也可以归到最高箱,不需要额外做填充。

def calc_woe_iv(df, feature, target, bins=5): # 等频分箱 df = df[[feature, target]].dropna().copy() df['bin'] = pd.qcut(df[feature], q=bins, duplicates='drop') grouped = df.groupby('bin')[target].agg(['count', 'sum']) grouped.columns = ['total', 'bad'] grouped['good'] = grouped['total'] - grouped['bad'] # 防止除零 grouped['bad'] = grouped['bad'].replace(0, 0.5) grouped['good'] = grouped['good'].replace(0, 0.5) grouped['bad_pct'] = grouped['bad'] / grouped['bad'].sum() grouped['good_pct'] = grouped['good'] / grouped['good'].sum() 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 for col in ['age', 'monthly_income', 'debt_ratio', 'query_3m', 'history_overdue']: _, iv = calc_woe_iv(train, col, 'label', bins=5) print(f"{col} 的 IV = {iv:.4f}")

这段代码用等频分箱把连续变量切成 5 段,然后计算每段的 WOE 和 IV。参数bins=5是起步值,实际项目中会根据 IV 调整,通常 3 到 8 箱比较常见。replace(0, 0.5)是为了避免某个箱里没有坏样本或好样本导致对数无穷大,这是血泪经验——不加这一步,代码会在某些稀疏特征上直接报错。

3.2 分箱单调性检查与手动调整

自动分箱出来的结果不一定单调。比如年龄分箱可能出现「青年风险高、中年风险低、老年风险又高」的 U 型,这种非单调关系在评分卡里很难解释,业务方会质疑「为什么 55 岁比 35 岁风险高」。常见做法是合并相邻箱直到 WOE 单调。

def merge_to_monotonic(grouped): # 按分箱顺序检查 WOE 是否单调,不单调则合并相邻箱 grouped = grouped.reset_index() while True: woe = grouped['woe'].values # 检查是否单调递增或递减 inc = all(woe[i] <= woe[i+1] for i in range(len(woe)-1)) dec = all(woe[i] >= woe[i+1] for i in range(len(woe)-1)) if inc or dec: break # 找到第一个违反单调的位置,合并该箱与下一箱 for i in range(len(woe)-1): if (woe[i] > woe[i+1] and not dec) or (woe[i] < woe[i+1] and not inc): grouped.loc[i, 'total'] += grouped.loc[i+1, 'total'] grouped.loc[i, 'bad'] += grouped.loc[i+1, 'bad'] grouped.loc[i, 'good'] += grouped.loc[i+1, 'good'] grouped = grouped.drop(i+1).reset_index(drop=True) break # 重新计算 WOE grouped['bad_pct'] = grouped['bad'] / grouped['bad'].sum() grouped['good_pct'] = grouped['good'] / grouped['good'].sum() grouped['woe'] = np.log(grouped['bad_pct'] / grouped['good_pct']) return grouped

合并逻辑是:只要 WOE 序列不是单调递增也不是单调递减,就找到第一个破坏单调性的相邻对,把它们合并,重新计算 WOE,直到单调为止。这个函数在实际项目里能省掉大量手工调箱的时间。但要注意,合并后箱数可能变得很少,如果少于 3 箱,这个特征的区分度可能已经不够了,需要考虑是否保留。

分箱完成后,把每个特征的 WOE 值映射回原始数据,形成 WOE 矩阵。这个矩阵就是后续逻辑回归的输入。

def apply_woe_mapping(df, feature, grouped): # 根据分箱边界把原始值映射为 WOE df = df.copy() bins = grouped['bin'].cat.categories if hasattr(grouped['bin'], 'cat') else grouped['bin'] # 用 pd.cut 按相同边界切分 df['bin'] = pd.cut(df[feature], bins=bins, duplicates='drop') woe_map = dict(zip(grouped['bin'], grouped['woe'])) df[feature + '_woe'] = df['bin'].map(woe_map).fillna(0) return df

映射时要注意:训练集的分箱边界必须保存下来,测试集和线上新样本都用同一套边界。如果每次都用新数据重新分箱,线上分数会漂移。我一般会把分箱边界和 WOE 映射表存成 JSON 或 pickle,和模型文件放在一起。

4. 逻辑回归建模与分数校准:从概率到 0-1000 分

4.1 用 WOE 矩阵训练逻辑回归并检查系数

申请评分卡的主流模型是逻辑回归,不是因为它效果最好,而是因为它可解释、系数方向明确、上线后容易监控。用 WOE 矩阵作为输入,逻辑回归的系数就代表了每个特征对风险的贡献方向。

from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score, roc_curve # 假设 woe_cols 是 WOE 特征列名列表 woe_cols = [c for c in train.columns if c.endswith('_woe')] X_train = train[woe_cols].fillna(0) y_train = train['label'] X_test = test[woe_cols].fillna(0) y_test = test['label'] model = LogisticRegression( penalty='l2', C=1.0, class_weight='balanced', max_iter=1000, solver='lbfgs' ) model.fit(X_train, y_train) # 输出系数 coef_df = pd.DataFrame({ 'feature': woe_cols, 'coef': model.coef_[0] }).sort_values('coef') print(coef_df) # 评估 train_pred = model.predict_proba(X_train)[:, 1] test_pred = model.predict_proba(X_test)[:, 1] print(f"训练集 AUC = {roc_auc_score(y_train, train_pred):.4f}") print(f"测试集 AUC = {roc_auc_score(y_test, test_pred):.4f}")

参数上,class_weight='balanced'是因为申请评分卡的坏样本率通常很低(5% 到 15%),不加权的话模型会偏向预测好样本。C=1.0是正则化强度的倒数,C 越小正则化越强,如果系数绝对值普遍偏大,可以降到 0.1 试试。max_iter=1000是防止迭代不收敛,WOE 特征之间相关性较高时容易不收敛。

系数检查是评分卡建模的关键一步。所有系数应该为负——WOE 越大代表风险越高,逻辑回归里 WOE 的系数应该为负,这样 WOE 增大时预测为坏的概率也增大。如果某个特征的系数为正,说明它和标签的关系反了,要么是分箱有问题,要么是这个特征在业务上就不该用。

4.2 把概率映射成 0-1000 分的标准做法

逻辑回归输出的是概率,业务方要的是分数。标准做法是用「基点 + 倍数」的线性映射:

score = A - B * ln(odds)

其中 odds = p / (1-p),p 是坏样本概率。A 和 B 由两个锚点决定:某个基准分数对应某个基准 odds,以及 odds 翻倍时分数下降多少。

import numpy as np # 设定锚点:基准分数 600 对应 odds=1:20(即坏好比 1/20) # 分数每降低 50 分,odds 翻倍 base_score = 600 base_odds = 1 / 20 pdo = 50 # points to double the odds B = pdo / np.log(2) A = base_score + B * np.log(base_odds) def prob_to_score(prob): # prob 是坏样本概率 prob = np.clip(prob, 1e-6, 1 - 1e-6) odds = prob / (1 - prob) score = A - B * np.log(odds) return np.round(score).astype(int) test_scores = prob_to_score(test_pred) print(f"测试集分数范围:{test_scores.min()} - {test_scores.max()}") print(f"测试集分数均值:{test_scores.mean():.1f}")

这段代码的逻辑是:先根据锚点算出 A 和 B,再把每个样本的坏概率转成 odds,最后用线性公式得到分数。参数base_score=600、base_odds=1/20、pdo=50是行业常见设定,但不同机构会调整。比如有的机构用 500 分对应 odds=1:50,pdo=40。关键是上线前和业务方确认好这套映射,因为分数阈值直接决定通过率。

分数映射完之后,要做一次分数分布对比:训练集和测试集的分数分布是否接近。如果测试集分数整体偏低,说明新客群风险在上升,可能需要调整通过阈值。

import matplotlib.pyplot as plt plt.figure(figsize=(10, 5)) plt.hist(prob_to_score(train_pred), bins=50, alpha=0.5, label='train', density=True) plt.hist(prob_to_score(test_pred), bins=50, alpha=0.5, label='test', density=True) plt.xlabel('score') plt.ylabel('density') plt.legend() plt.title('Score Distribution: Train vs Test') plt.show()

如果两条分布曲线错位明显,先别急着上线,回头检查测试集的时间段是不是客群发生了结构性变化,或者某个特征在测试集上出现了训练集没见过的取值。

5. 避坑与排查:申请评分卡建模中最容易翻车的 5 个地方

5.1 现象:AUC 很高但上线后坏账率没降

原因:随机切分导致数据泄漏。同一时期的样本同时出现在训练和测试里,模型记住了那个时期的客群特征,时间外样本上表现大幅下降。

解决:强制按时间切分,并且用 PSI 检查每个特征在训练集和测试集上的分布差异。PSI 大于 0.25 的特征要么剔除,要么只保留粗分箱。

5.2 现象:某个特征的逻辑回归系数为正

原因:WOE 分箱时把好坏样本比例算反了,或者这个特征本身和标签的关系就是正的(比如「历史逾期次数」越多风险越高,但 WOE 计算时坏样本占比反而下降)。

解决:先检查 WOE 计算公式,确认分子是坏样本占比、分母是好样本占比。如果公式没错,检查这个特征的分箱是否合理,必要时手动调整分箱边界。如果调整后系数仍为正,考虑剔除该特征。

5.3 现象:模型在测试集上 AUC 0.75,但分数分布和训练集错位严重

原因:训练集和测试集的坏样本率差异大,或者某个关键特征在测试集上缺失率高。

解决:先对比两段的坏样本率,如果差异超过 50%,说明客群迁移明显,需要重新审视观察点和表现期的设定。然后检查每个特征在两段的缺失率,缺失率差异大的特征要做特殊处理。

5.4 现象:分箱后某些箱的样本量极少

原因:等频分箱在数据分布不均匀时会切出样本量差异很大的箱,极端情况下某个箱只有几十个样本。

解决:分箱后检查每箱样本量,少于总样本 5% 的箱要和相邻箱合并。合并后重新计算 WOE 和 IV。如果合并后箱数少于 3,考虑这个特征是否值得保留。

5.5 现象:上线后分数整体漂移,通过率异常波动

原因:线上新样本的 WOE 映射和训练时不一致,或者某个特征的取值超出了训练集的分箱边界。

解决:把训练时的分箱边界和 WOE 映射表固化下来,线上用同一套映射。对于超出边界的取值,归到最近的箱,不要重新分箱。同时建立分数监控,每天统计分数分布和通过率,发现漂移及时排查。

6. 进阶技巧:用评分卡做通过率模拟与阈值选择

模型训完、分数映射完,最后一步是选阈值。阈值不是拍脑袋定的,要用通过率模拟来选。做法是:把测试集按分数从高到低排序,设定不同的分数阈值,计算每个阈值下的通过率和通过样本的坏账率,画出一条权衡曲线。

def simulate_threshold(scores, labels, thresholds): results = [] for t in thresholds: approved = scores >= t approval_rate = approved.mean() if approved.sum() == 0: bad_rate = 0 else: bad_rate = labels[approved].mean() results.append({ 'threshold': t, 'approval_rate': approval_rate, 'bad_rate': bad_rate, 'approved_bad_count': labels[approved].sum() }) return pd.DataFrame(results) thresholds = np.arange(400, 800, 20) sim_df = simulate_threshold(test_scores, y_test.values, thresholds) print(sim_df)

这段代码的逻辑是:对每个候选阈值,计算通过率(分数大于等于阈值的样本占比)和通过样本的坏账率。业务方通常会给一个目标坏账率,比如「通过样本的坏账率控制在 3% 以内」,然后从模拟结果里找到满足条件的最高阈值,这样通过率最大。

参数上,thresholds的范围要根据实际分数分布调整。如果测试集分数集中在 500 到 700,就从 500 扫到 700,步长 10 或 20。步长太粗会漏掉最优阈值,太细则计算量大但收益有限。

除了通过率模拟,还有一个进阶做法是分段授信。不同分数段给不同额度,分数高的给高额度,分数低的给低额度。这需要把额度也纳入模拟,计算每个分数段的预期损失。预期损失 = 通过率 × 坏账率 × 平均损失金额。这个计算需要业务方提供平均损失金额的估计,通常用历史坏账的平均未还本金来近似。

我自己的习惯是:每次做完评分卡,都会把分箱边界、WOE 映射表、模型系数、分数映射参数、阈值模拟结果这五样东西打包成一个版本目录,目录名带上日期和 AUC。上线后每周跑一次分数分布监控,每月跑一次 PSI 和 AUC 回溯。这套流程跑顺了,评分卡就不是一次性的建模项目,而是一个可以持续迭代的风控组件。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 17:40:53

从零搭建私有文档问答系统:向量数据库选型与检索调优实战

1. 从零搭建私有文档问答系统&#xff1a;为什么向量检索是绕不开的一环大模型火起来之后&#xff0c;我身边不少做后端和算法的朋友都动过一个念头&#xff1a;能不能把公司内部那堆散落在各个角落的文档、手册、会议纪要整合起来&#xff0c;做一个能直接问答的私有知识库。想…

作者头像 李华
网站建设 2026/10/10 17:40:06

绝版回放:Claude Code 直连 V4 Pro 0813 的最后一套可用配置

绝版回放&#xff1a;Claude Code 直连 V4 Pro 0813 的最后一套可用配置 【免费下载链接】DeepSeek-V4-Pro-0813 项目地址: https://ai.gitcode.com/hf_mirrors/deepseek-ai/DeepSeek-V4-Pro-0813 当 DeepSeek 官方宣布 V4.1 Flash 上线的同时&#xff0c;把 V4 Pro 服…

作者头像 李华
网站建设 2026/10/10 17:40:03

插件万能论 vs 插件围城论:Codex 生态越繁荣,开发者越焦虑?

插件万能论 vs 插件围城论&#xff1a;Codex 生态越繁荣&#xff0c;开发者越焦虑&#xff1f; 【免费下载链接】plugins OpenAI Plugins 项目地址: https://gitcode.com/GitHub_Trending/plugins123/plugins 2026 年的 Codex 生态呈现出一幅奇特的镜像&#xff1a;一边…

作者头像 李华
网站建设 2026/10/10 17:38:03

Python车牌识别计费系统实战:OpenCV+Tesseract+SQLite全链路解析

简介&#xff1a;这是一套面向Python初学者与计算机专业学生的智能停车场车牌识别计费系统完整源码&#xff0c;基于百度AI开放平台图片识别接口实现车牌自动识别、车辆出入场判断、收入统计与车位满预警等核心业务&#xff0c;适合用作课程设计、毕业设计或Python桌面应用练手…

作者头像 李华
网站建设 2026/10/10 17:34:23

车桥耦合振动分析:基于Newmark法的Matlab完整实现与排错技巧

车桥耦合振动分析&#xff0c;做过桥梁动力学的人基本都绕不过去。这类问题的难点不在理论本身&#xff0c;而在数值实现——怎么把车辆和桥梁两套系统耦合在一起求解&#xff0c;怎么保证时间积分稳定收敛&#xff0c;怎么把Matlab程序写得既准确又不拖沓。我最初接触这个课题…

作者头像 李华