简介:这套基于 Python 的逻辑回归评分卡模型资源,面向金融风控、信贷评分等入门与进阶学习者,也适合毕业设计、课程作业或工程实训。项目完整覆盖特征工程、WOE 编码、IV 值计算与特征筛选、特征 WOE 化,直至评分卡建模,并支持输入筛选后属性的特征值自动输出评分结果,便于快速理解评分卡从数据到分数的完整链路。压缩包共 6 个文件,约 5.07MB,包含 3 个 CSV 训练/测试数据、1 个数据字典 XLS、1 个 Python 主脚本 score.py 以及说明文档 README.md,结构清晰,可对照数据与代码逐步运行。已有 152 人学习查看,适合希望以真实数据集动手实践逻辑回归评分卡,并希望获得可直接复用脚本与数据处理思路的学习者。
1. 逻辑回归评分卡:先看流程,再谈建模
很多第一次接触评分卡项目的人,拿到资源包第一件事就是pip install sklearn,然后直接拿原始特征训练逻辑回归。这么做大概率交不了差。评分卡模型的难点从来不在逻辑回归本身——逻辑回归只是最后那一层拟合,真正的核心在特征工程、WOE 编码,以及把模型系数换算成人能读懂的整数分数。这份资源给了一套完整闭环:cs-training 训练集、cs-test 测试集、sampleEntry 提交样例、Data-Dictionary 数据字典,外加一个 score.py 主脚本。适合两类人,一类是做课程设计或毕设的 Python 方向学生,需要在有限时间内把全流程跑通;另一类是刚转数据岗、想搞懂「模型输出怎么变成评分卡」的开发。本文按我复盘这个项目的顺序展开,把数据、特征、建模、踩坑一次说透。
2. 数据包与字段体检:cs-training 里藏着的建模前提
2.1 文件结构与数据形态:先盘一遍资源里有什么
解压 score_logistic-master.zip 之后,目录里主要是训练集、测试集、提交样例、数据字典和建模脚本。这五个东西正好对应一条完整的数据建模流水线:训练数据用来拟合,测试数据用来预测,sampleEntry 规定了预测结果的输出格式,Data-Dictionary 解释每个字段的业务含义,score.py 则是把整条流程串起来的核心脚本。
import pandas as pd train = pd.read_csv("cs-training.csv") test = pd.read_csv("cs-test.csv") print("train shape:", train.shape) print("test shape:", test.shape) print(train.head(3))训练集大约 15 万行、12 列左右,测试集略少一些。这里有个容易忽略的细节:两个 CSV 的第一列通常是 Unnamed: 0,也就是原始行号,读取后先用drop(columns=["Unnamed: 0"])删掉,否则后面特征筛选会把这个索引列也算进去。我一般读取时就处理好:
train = train.drop(columns=[c for c in train.columns if "Unnamed" in c])数据形态确认后,下一步就是弄清楚每个字段的含义,不能拿着列名瞎猜。
2.2 数据字典与目标变量:每个字段先翻译成业务语言
打开 Data-Dictionary.xls 能看到完整字段说明。目标变量是SeriousDlqin2yrs,表示客户在未来两年内是否出现严重逾期(逾期 90 天以上),1 代表坏客户,0 代表好客户。这是二分类问题,也是评分卡最常见的建模场景。
剩下的字段基本都是客户维度的信用和行为特征,核心的几列整理如下:
| 字段 | 业务含义 | 说明 |
|---|---|---|
| RevolvingUtilizationOfUnsecuredLines | 信用卡和无担保贷款额度使用率 | 数值型,越接近 1 说明额度用得越满 |
| age | 年龄 | 数值型,注意是否有异常值 |
| NumberOfTime30-59DaysPastDueNotWorse | 30-59 天逾期次数 | 计数型,有大量重复值 |
| DebtRatio | 月负债/月收入 | 数值型,分布极端 |
| MonthlyIncome | 月收入 | 数值型,缺失率最高 |
| NumberOfOpenCreditLinesAndLoans | 未结清贷款和信用卡笔数 | 计数型 |
| NumberOfTimes90DaysLate | 90 天以上逾期次数 | 计数型 |
| NumberRealEstateLoansOrLines | 不动产贷款笔数 | 计数型 |
| NumberOfTime60-89DaysPastDueNotWorse | 60-89 天逾期次数 | 计数型 |
| NumberOfDependents | 家属人数 | 计数型,有缺失 |
看到这套字段,基本可以判断这份资源和 Kaggle 上经典的信用评分竞赛同源。字段里有多个「逾期次数」变量,而且按逾期天数分了三档,这说明数据本身对历史还款行为做了粗粒度的分级,后续分箱时这些计数特征的表现通常很稳定。
2.3 缺失率与分布体检:建模前先做一次全面体检
拿到数据不急着建模,先做缺失率和描述性统计。这一步能省掉后面大量排查时间。评分卡项目里最常翻车的两个点就是缺失值处理和分箱边界,而这两个问题的源头都在这里。
missing_rate = train.isnull().mean().sort_values(ascending=False) print(missing_rate[missing_rate > 0]) desc = train[["MonthlyIncome", "age", "DebtRatio"]].describe() print(desc)跑完会发现两个明显的坑:MonthlyIncome缺失率接近 20%,NumberOfDependents缺失率约 2%~3%,其他字段基本干净。这是典型的「系统缺失」,并非随机零星缺失,所以不能简单填 0 或填均值。填 0 会把大量真实收入信息扭曲成「无收入」,导致后面 WOE 编码时最低收入箱的坏客户占比异常高。
再看分布,MonthlyIncome均值远大于中位数,典型的右偏分布,还有少数极端大额收入;DebtRatio同样存在离谱的离群值。这些字段直接喂进逻辑回归,系数会被极端样本拽得七扭八歪,所以必须做分箱和映射,也就是下一章要讲的 WOE 编码。
提示:这一步建议把缺失率和 describe 的结果存下来,后面做完特征工程再对比一次,能明显看到分箱后极端值的影响被削弱了。
3. 特征工程与 WOE 编码:为什么原始特征跑不出靠谱分数
3.1 逻辑回归的线性假设:原始特征与目标之间隔着一道坎
逻辑回归本质是线性模型,它拟合的是特征与目标对数值之间的线性关系。但信用评分场景里,年龄、收入、负债比这类变量和目标变量之间往往不是直线关系,更常见的是「倒 U 型」或「分段跳跃」:年龄太小和太大逾期风险都高,中间段最稳;收入到一定水平之后,再涨对风险的影响就很小了。
如果直接把原始数值丢进模型,等于强迫一条直线去拟合这种非线性关系,结果就是欠拟合,很多变量的预测力被白白浪费。另一个问题是极端值,比如月收入出现一个 800 万的值,这条样本会把整个特征的系数拉偏,而且很难通过标准化彻底消除。
行业里解决这个问题的主流方式就是分箱加 WOE 编码,把每个连续变量切成若干段,每段用该段好坏客户占比的比值作为新特征值。这套做法在评分卡项目里几乎是标配,既规避了线性假设,又天然化解了极端值的影响。
3.2 WOE 与 IV 的含义:这段代码在算什么
WOE(Weight of Evidence,证据权重)的公式长这样,按坏好比口径计算:
WOE_i = ln( (bad_i / bad_total) / (good_i / good_total) )其中 bad_i 表示第 i 个分箱里坏客户数量,good_i 是好客户数量。每个箱子算出一个 WOE 值,代表这个箱子的风险浓度:WOE 越大,说明这个箱子里坏客户占比相对更高,风险也就越高。
IV(Information Value)则是对每个变量整体预测力的度量:
IV = Σ (bad_i / bad_total - good_i / good_total) × WOE_iIV 值越高,说明这个变量区分好坏客户的能力越强。实际项目中一般按这个标准筛选:
| IV 值范围 | 预测力判断 | 处理建议 |
|---|---|---|
| < 0.02 | 几乎没有预测力 | 直接剔除 |
| 0.02 ~ 0.1 | 弱 | 可保留,谨慎使用 |
| 0.1 ~ 0.3 | 中等 | 保留,主力特征 |
| 0.3 ~ 0.5 | 强 | 优先保留 |
| > 0.5 | 过强,需复核 | 检查是否有泄漏或异常 |
3.3 分箱与 WOE 计算代码:一份能直接跑的通用的工具函数
我习惯写一个通用函数,输入 DataFrame、特征列名和目标列名,输出每个分箱的样本数、好坏占比、WOE 和 IV。等频分箱用pd.qcut,缺失值单独成箱,不参与数值分箱。
import numpy as np import pandas as pd def woe_1d(df, col, target, bins=10): temp = df[[col, target]].copy() # 缺失值单独标记,不参与数值分箱 temp[col] = temp[col].fillna("MISSING") mask = temp[col] != "MISSING" # 等频分箱,duplicates='drop' 避免重复边界报错 temp.loc[mask, col] = pd.qcut( temp.loc[mask, col].astype(float), q=bins, duplicates="drop" ).astype(str) grouped = temp.groupby(col, as_index=False)[target].agg(["sum", "count"]) grouped["good"] = grouped["count"] - grouped["sum"] grouped["bad"] = grouped["sum"] good_total = grouped["good"].sum() bad_total = grouped["bad"].sum() grouped["distr_good"] = grouped["good"] / good_total grouped["distr_bad"] = grouped["bad"] / bad_total grouped["woe"] = np.log(grouped["distr_bad"] / grouped["distr_good"]) grouped["iv"] = (grouped["distr_bad"] - grouped["distr_good"]) * grouped["woe"] return grouped这段代码的核心逻辑是先把缺失值固定为MISSING箱,再做等频分箱,最后按公式逐箱计算。这里有两个值得注意的设计:缺失值单独成箱可以避免填充值对分布的污染;使用duplicates="drop"可以规避计数特征因大量重复导致的 qcut 报错。
调用方式也很简单,传训练集和目标列进去,就能拿到单变量的全部分箱明细:
result = woe_1d(train, "age", "SeriousDlqin2yrs", bins=10) print(result[["age", "good", "bad", "woe", "iv"]])结果里能看到每个年龄段的好坏分布和 WOE 趋势。如果某个箱子的 WOE 突然跳得很高,说明这个段的客户风险确实异常,这是分箱希望捕捉到的信息,不需要修复。
3.4 用 IV 筛选特征:留下有用的,删掉凑数的
对每个特征跑一遍上面的函数,然后把 IV 汇总对比,做特征初筛。这里不建议只看单变量 IV 排序,还要结合业务理解判断,有些变量 IV 不高但业务意义明确(比如年龄),强行删掉会损失模型的解释性。
feature_cols = [ "RevolvingUtilizationOfUnsecuredLines", "age", "NumberOfTime30-59DaysPastDueNotWorse", "DebtRatio", "MonthlyIncome", "NumberOfOpenCreditLinesAndLoans", "NumberOfTimes90DaysLate", "NumberRealEstateLoansOrLines", "NumberOfTime60-89DaysPastDueNotWorse", "NumberOfDependents" ] iv_list = [] for col in feature_cols: grouped = woe_1d(train, col, "SeriousDlqin2yrs", bins=10) iv_list.append((col, round(grouped["iv"].sum(), 4))) iv_list.sort(key=lambda x: x[1], reverse=True) for col, iv in iv_list: print(f"{col:<45} IV={iv}")跑完之后我一般会把 IV 小于 0.02 的特征删掉。这份资源里通常能留下 6~8 个有效特征,其中逾期次数类变量和额度使用率的 IV 最高,这符合风控常识:历史逾期行为对未来的预测力极强,远高于收入、年龄这类基础信息。
提示:IV 筛选只是粗筛,最终是否保留还要看变量之间的相关性。比如三个逾期次数变量相关性很高,全保留会导致多重共线性,后面训练时可以观察系数是否异常。
4. 评分卡建模与分数换算:系数到整数分三步走
4.1 特征 WOE 化:把训练集每一列变成 WOE 值
特征筛选完成后,要把训练集原始值替换成对应的 WOE 值。做法是复用上一步的分箱结果,先训练时的分箱保存,再把每个特征的值映射成所在箱子的 WOE。
这里有个关键点:测试集不能自己重新分箱,必须用训练集的分箱边界去切。否则训练和测试的箱子对不齐,分数根本没有可比性。所以我一般把分箱边界和 WOE 映射表同时存成字典,供训练集和测试集共用。
def build_woe_map(df, cols, target, bins=10): woe_maps = {} for col in cols: grouped = woe_1d(df, col, target, bins=bins) woe_maps[col] = dict(zip(grouped[col], grouped["woe"])) return woe_maps woe_map = build_woe_map(train, feature_cols, "SeriousDlqin2yrs", bins=10)映射替换时记得先把缺失值统一替换成MISSING再执行 map,否则缺失值会匹配不到键。替换完后的训练集每一列都是数值型 WOE,可以直接用来训练逻辑回归。
4.2 逻辑回归训练:用 statsmodels 还是 sklearn
评分卡建模时,我推荐用 statsmodels 而不是 sklearn。评分卡项目讲究可解释性,statsmodels 的 summary 能直接输出每个特征的 P 值和置信区间,写课程设计或毕设报告时这些数字直接可以用。sklearn 也能做,但系数显著性要另外算。
import statsmodels.api as sm train_woe = train.copy() for col in feature_cols: map_dict = woe_map[col] train_woe[col] = train_woe[col].fillna("MISSING").map(map_dict) X = train_woe[feature_cols] y = train_woe["SeriousDlqin2yrs"] X_const = sm.add_constant(X) model = sm.Logit(y, X_const).fit() print(model.summary())solver层面 statsmodels 默认使用误差函数,对这份数据规模完全没有压力。训练完成后重点看两样东西:P 值是否都小于 0.05,系数方向是否符合业务认知。比如逾期次数类变量的系数应该为正,说明逾期次数越多、WOE 越高、风险越大。如果某个系数的符号和业务直觉相反,优先怀疑是多重共线性,而不是直接接受结果。
4.3 从系数到标准分:评分尺度的核心公式
逻辑回归的系数出来之后,还差最关键的一步:把 odds 换算成标准分。评分卡的标准公式如下,按坏好比口径定义 odds:
Score = A - B * ln(odds) B = PDO / ln(2) A = base_score + B * ln(base_odds)这里base_score是基准分,base_odds是基准坏好比例,PDO(Points to Double the Odds)表示 odds 翻倍时分数变化的点数。注意公式里是减号,所以风险越高、odds 越大,分数越低,符合评分卡「分数越高信用越好」的直觉。
一个常见参数设定是:基准分 600 分,基准坏好比 1:10,PDO=50。含义是当坏好比例从 1:10 变成 1:20 时(好事),分数增加 50 分;反过来风险翻倍时扣 50 分。代码实现如下:
base_score = 600 base_odds = 0.1 # 坏好比例 1:10 pdo = 50 B = pdo / np.log(2) A = base_score + B * np.log(base_odds) coef = model.params intercept = coef["const"] n_feat = len(feature_cols) score_maps = {} for col in feature_cols: score_maps[col] = {} for box, w in woe_map[col].items(): # 把截距平均分摊到每个特征,再算每个箱子的得分 score_maps[col][box] = -B * (coef[col] * w + intercept / n_feat) + A / n_feat这段代码的核心是把总分公式拆解到每一个分箱,每个箱子算出一个独立的整数分贡献。把所有箱子贡献相加,正好等于整张评分卡的最终分数。这样交付时就能做成一张「查分表」:客户年龄落在哪箱、逾期次数落在哪箱,直接查表累加。
4.4 预测流程:输入一行特征,自动输出评分
模型训练完成后,写一个预测入口,输入特征数据,输出评分结果。这是资源中 score.py 的核心功能,也是交付时最常用的接口。注意分箱边界必须沿用训练时的 qcut 边界,不能重新计算。
def predict_score(row, model, woe_map, bin_edges, feature_cols): b = pdo / np.log(2) a = base_score + b * np.log(base_odds) total = a for col in feature_cols: val = row[col] if pd.isna(val): box = "MISSING" else: # 用训练时的边界切分,得到区间字符串 box = str(pd.cut([val], bins=bin_edges[col])[0]) total += score_maps[col].get(box, 0) return int(round(total))这里多了一个bin_edges,是训练分箱时保存的边界数组。预测时先用pd.cut把新样本切进对应的箱子,再取该箱得分累加。如果新样本的值落在训练范围之外,pd.cut会返回 NaN,需要加一个兜底逻辑,否则get不到分数会把总分算错。
5. 常见坑与排查:环境、分箱边界与分数方向
5.1 环境冲突:pip 装包失败、vscode 里 import 报错
现象:pip install statsmodels装到一半报编译错误,或者代码在 vscode 里运行时报ModuleNotFoundError: No module named 'statsmodels'。
原因:最常见的是 Python 版本过高,比如直接用 3.12 跑老项目的依赖,statsmodels 老版本没有对应 cp312 的预编译包,只能现场编译,很容易失败;其次是 vscode 里选了全局解释器,项目装在虚拟环境里,解释器对不上。
解决:先建独立虚拟环境再装依赖,Python 版本控制在 3.8~3.10 之间最稳。
python -m venv score_env source score_env/bin/activate # Windows 用 score_env\Scripts\activate pip install pandas numpy scikit-learn statsmodels openpyxl装完在 vscode 里按 Ctrl+Shift+P 选择 Python 解释器,选到 score_env 下的 python。如果默认源下载慢导致超时,把 pip 源切到国内镜像重装一次即可。
5.2 等频分箱报错:Bin edges must be unique
现象:跑pd.qcut时报ValueError: Bin edges must be unique,程序直接中断。
原因:等频分箱按分位数切分,但像NumberOfTimes90DaysLate这种计数特征有大量重复值,比如 80% 的样本都是 0,分位数切出来的边界重复,qcut 无法生成唯一区间。
解决:一个办法是在调用时加duplicates="drop",让 qcut 自动合并重复边界,代价是实际分箱数会少于设定值。如果希望分箱数稳定,可以先用rank(method="first")把重复值打散再切,效果更可控。
temp["rank_col"] = temp[col].rank(method="first") temp["bin"] = pd.qcut(temp["rank_col"], q=10, duplicates="drop")5.3 缺失值先填 0 再算 WOE,导致 IV 虚高
现象:把MonthlyIncome的缺失值直接 fillna(0) 后再分箱,结果收入最低的那个箱坏客户占比异常高,这个特征的 IV 比不处理时高出一大截。
原因:缺失值和真实的 0 收入是两回事。填 0 等于人为制造了一批「无收入」样本,分箱时它们集中掉进最低箱,箱内坏客户比例被虚假放大,WOE 和 IV 自然失真。
解决:缺失值单独成箱,不参与数值分箱。这是评分卡项目里处理缺失的标准姿势。WOE 编码本身就支持「缺失」作为一个独立水平进入模型,没必要提前填充。
5.4 评分方向反了:坏客户分数比好客户还高
现象:模型训练完,抽样几条数据预测,发现逾期客户的评分比正常客户高,和业务直觉完全相反。
原因:公式里 odds 口径没统一。如果ln(odds)用的是好/坏比例,而公式仍然是Score = A - B * ln(odds),那风险越高 ln(odds) 越小,分数反而越高。
解决:统一按坏好比例定义 odds。训练完做一个方向性检验:对每个特征,把各箱的 WOE 和分数列出来,检查 WOE 越高分数是否越低。如果方向反了,把公式里的减号改成加号,或者在 odds 定义处取倒数,二选一,不要两个都改。
5.5 测试集预测结果对不上提交格式
现象:用测试集预测完,拼到 sampleEntry.csv 里发现顺序混乱,或者 Id 对不上,AUC 结果异常低。
原因:test 读进来后可能被重置过索引,也可能原始文件本来就乱序,直接按行拼接预测结果自然错位。
解决:预测前先把测试集的原始 Id 列保留,预测完按 Id 对齐再输出。最稳妥的做法是预测后做一次显式排序,而不是依赖内存顺序。
test["pred_score"] = test.apply(lambda r: predict_score(r, ...), axis=1) result = test[["id", "pred_score"]].sort_values("id") result.to_csv("pred_result.csv", index=False)5.6 分箱过细导致 WOE 出现无穷值
现象:某个箱子好客户数量为 0,np.log(0)得到负无穷,distr_bad / distr_good分母为 0 得到 inf,后续训练直接报错或系数爆炸。
原因:分箱数设得太大,比如超过 20 箱,细尾箱样本量过小,坏客户或好客户可能出现 0 计数。
解决:分箱数控制在 5~10 之间;计算 WOE 时对占比做平滑处理,给分子分母同时加一个极小值,比如 0.5,避免除零。
6. 模型落地与验证:把评分卡从黑匣子变成交付物
模型训完只是第一步,交付才是重点。评分卡项目的交付物从来不是模型对象,而是一套能让业务方看懂、能复核的「分数表」。我通常会做两件事:把 score_maps 输出成 Excel 多 sheet 明细,把预测函数做成分数分布验证,确认评分的单调性。
输出 Excel 明细这一步非常实用。每个特征一个 sheet,列名包括分箱区间、好坏客户数、WOE、得分。这样风控同事可以直接看表格,看到「年龄 18~25 岁这段得分是 -12 分」,而不是对着系数发呆。这个表也是毕设报告里最好的附录材料。
with pd.ExcelWriter("scorecard_detail.xlsx", engine="openpyxl") as writer: for col in feature_cols: grouped = woe_1d(train, col, "SeriousDlqin2yrs", bins=10) grouped["score"] = grouped[col].map(score_maps[col]) grouped.to_excel(writer, sheet_name=col, index=False)表格生成后还要做一次分数单调性验证。把训练集按预测分数从低到高切 10 段,统计每段坏客户占比。一段合格的评分卡,分数段从低到高,坏客户占比应该严格递减。如果中段出现反弹,优先检查分箱是否合理,尤其是有没有分箱数量过少导致的信息丢失。
train["pred_score"] = train.apply(lambda r: predict_score(r, model, woe_map, bin_edges, feature_cols), axis=1) train["score_bin"] = pd.qcut(train["pred_score"], q=10, duplicates="drop") check = train.groupby("score_bin", observed=True)["SeriousDlqin2yrs"].mean() print(check)这个单调性检验做完,整张评分卡才算闭环。从那以后我每次做评分卡项目,都会强制走一遍这套验证流程:先看分数分布方向,再看分段逾期率单调性,最后才敢把结果交出去。这份资源把数据、脚本、文档都配齐了,按这条链路跑完一遍,你会对逻辑回归评分卡有一个完整且落地的认知,希望帮到你。
本文还有配套的精品资源,点击获取