☰
基于Python的逻辑回归评分卡模型构建全解析
2026/10/3 2:44:41 网站建设 项目流程

简介:基于Python的逻辑回归评分卡建模项目,面向希望系统掌握机器学习评分卡技术的初学者和进阶学习者,完整覆盖特征工程、WOE编码、IV值计算与特征筛选、特征WOE化以及评分卡建模五个关键环节。项目中输入筛选后特征的属性值即可自动得出评分结果,逻辑清晰,适合作为毕业设计、课程设计、大作业或工程实训的参考方案。资源包共6个文件,包含3个CSV数据文件、1个Excel数据字典、1个Python脚本和1个Markdown说明文档,整体大小约5.07MB,目录结构简单明了。已有152人学习浏览,适合用于评分卡模型的入门实践。通过数据字典可快速了解字段含义,CSV文件提供了现成的训练集与测试集,Python脚本实现了从特征处理到评分输出的完整流程,配合说明文档可深入理解每一步的计算原理与代码实现,方便读者复现实验或在此基础上进行二次开发。

1. 评分卡模型入门:逻辑回归就是那张"加减分表"

信贷审批室里,业务方最常问的不是"违约概率多少",而是"客户几分"。一个申请人620分,另一个680分,这60分到底差在哪,必须能指着打分表说清楚:年龄、收入、负债率各自贡献了多少。评分卡模型就是这张表——每个特征分成几档,每档对应一个固定分数,加总得到总分。逻辑回归恰好是它最稳的载体,训练出来的系数本身就是线性权重,不需要黑匣子式的高维模型。这篇笔记顺着"基于Python构建基于逻辑回归的评分卡模型"这条线,从分箱、WOE计算、逻辑回归训练到分数映射完整走一遍代码,把最容易翻车的环节单独列出来。适合正在做信贷风控建模、从数据分析转模型岗、或者需要复核现有评分卡逻辑的工程师参考。

2. 逻辑回归与评分卡的数学衔接:为什么是逻辑回归,分数从哪来

2.1 逻辑回归输出的是概率而不是分数:对数几率与交叉熵损失

逻辑回归常被归类为分类模型,但在评分卡语境里,它本质上是一个概率模型。假设坏客户取1、好客户取0,模型估计的是P(y=1|X),也就是申请人在给定特征下的违约概率。这个概率由线性组合z = β0 + β1x1 + ... + βnxn 通过sigmoid函数映射得到:

P = 1 / (1 + e^(-z))

把上式变形得到ln(P/(1-P)) = z,左边是对数几率,也叫log odds。这一步变形是整个评分卡的起点——信用分数恰好定义在log odds的线性函数上,所以逻辑回归输出的z天然对应分数的坐标轴,不需要额外做概率到分数的非线性转换。

训练逻辑回归时常用交叉熵损失,而不是回归任务里惯用的均方误差。原因是MSE配sigmoid会把损失函数变成非凸函数,梯度下降容易卡在局部最优点;交叉熵的负对数似然在逻辑回归下是凸优化,用lbfgs或牛顿法能稳定收敛。实际调参时有个容易忽略的点:损失函数里y=1和y=0两项默认权重相等,但如果坏样本占比只有5%甚至更低,模型会整体往"全部判好"偏移。评分卡建模因此离不开class_weight="balanced"或手动设置坏样本权重,这个参数会直接影响后面所有特征的系数,具体设置在章节4给出。

2.2 分数映射公式:A、B两个参数怎么定

有了逻辑回归的系数,下一步是把log odds换算成业务能懂的信用分数。评分卡的标准映射是:

Score = A - B · ln(odds)

这里的odds定义为坏概率/好概率。用减号的意义很直观:odds越高代表风险越高,分数必须越低。A是基准分,B是刻度,要确定这两个数,业界惯例是给两个业务假设:

  1. 某个基准odds对应一个基准分。比如odds = 1:19,对应约5%的坏客户率,给定分数600。
  2. odds翻倍时,分数下降PDO分。比如PDO=50,表示odds从1:19变成2:19时,分数从600降到550。

由条件2可得 B = PDO / ln2。以PDO=50算,B约72.13。再由条件1反推A = 基准分 + B·ln(基准odds),代入600和1:19算下来A约387.9。这两个数是整张评分卡的刻度依据,换一组假设就是完全不同的分数体系。

B的取值决定了分数对风险的敏感度。B越大,同样的odds变化带来的分数波动越大,审批cutoff两边的错杀和错放会被放大;实务中PDO常用20到60,基准分选500到600方便业务记忆。提示:有些资料把odds定义为好概率/坏概率,公式会写成Score = A + B·ln(odds好),本质效果一样,但抄公式前一定要先确认自己手里的odds方向,否则评分方向会整体镜像。

2.3 为什么标准评分卡一定要做WOE编码

直接拿原始特征如年龄、收入、负债率训练逻辑回归也能得到系数,但评分卡落地会立刻遇到三个问题:特征与对数几率之间往往不是线性关系,比如收入对违约的影响在低收入段很敏感、高收入段几乎不敏感;缺失值在原始尺度上没法直接参与回归;极端离群值会拉着系数跑偏。WOE编码能一次性处理这三个问题。

WOE,全称Weight of Evidence,把变量的每个分箱替换成该箱的证据权重。第i箱的WOE定义为:

WOE_i = ln(好客户占比_i / 坏客户占比_i)

好客户占比是该箱好客户数占总好客户数的比例,坏客户占比同理。WOE为正说明这一箱好客户相对偏多、风险较低;为负则相反。把原始变量替换成WOE后,逻辑回归的对数几率与WOE之间的线性关系比原始值稳定得多,每个分箱对风险的贡献是一个固定数值,天然适配评分卡的加减分结构。

还有一个实践优势:WOE自带处理缺失值的能力。缺失值单独开一箱,如果算出的WOE接近0,说明缺失基本无信息;如果明显偏离0,说明"没填收入"本身就是风险信号。这个点在章节5的踩坑记录里会专门展开。理论部分到这里闭环:概率与log odds、A/B刻度、WOE编码,分别回答"模型学的是什么""分数怎么换算""特征怎么进模型"三个问题。

3. 特征分箱与WOE/IV计算:这一步决定了模型上限

3.1 分箱方法对比:等频、等距与自动分箱怎么选

分箱是评分卡建模里最"手工"也最影响结果的一步,常见三种做法:

  1. 等频分箱:按样本量等分,用pd.qcut实现。优点是每箱样本量接近,后续WOE估计方差小;缺点是大量重复值时候容易报错,边界不一定是业务可解释的整数。适合连续变量首轮粗分。
  2. 等距分箱:按数值区间等宽划分。边界好解释但样本分布不均,长尾变量会出现空箱或极少数样本箱,导致WOE不稳定。
  3. 自动分箱:卡方分箱和决策树分箱,把相邻箱按目标分布相似性合并,能做自动的最优分箱。卡方分箱需要自己写合并逻辑,代码量不小;决策树可以用sklearn的DecisionTreeClassifier把预测概率作为切分点。这两种适合在等频粗分之后做精调。

我一般的做法是:连续变量先用等频分5到10箱,看WOE是否单调;不单调的箱与相邻箱合并,直到趋势单调或某箱样本量低于阈值。分箱数太少会丢信息,太多则每箱样本少、WOE波动大。经验法则是每箱坏样本和好样本都不少于30个,实际项目里低于这个数我会收缩边界。

3.2 用Python实现等频分箱与WOE/IV计算

先构造一份模拟信贷数据,包含年龄、收入、信用使用率、负债率四个特征,target为1表示坏客户:

import pandas as pd import numpy as np rng = np.random.default_rng(42) n = 5000 df = pd.DataFrame({ "age": rng.normal(35, 8, n).clip(20, 65).round(0), "income": rng.lognormal(10.5, 0.4, n).round(0), "credit_usage": rng.beta(2, 5, n).clip(0, 1), "debt_ratio": rng.beta(2, 4, n).clip(0, 1), }) # 造标签:信用使用率越高、收入越低、负债率越高 -> 违约概率越大 z = -1.5 + 3.0 * df["credit_usage"] - 0.0001 * df["income"] + 0.5 * df["debt_ratio"] df["target"] = rng.binomial(1, 1 / (1 + np.exp(-z))) print(df.head()) print("坏样本占比:", df["target"].mean())

这段模拟了5000条样本,credit_usage用beta(2,5)生成右偏分布,更接近真实信贷场景。beta两个参数决定分布形状,数值越大越往1偏,实际数据探索阶段可以先用describe和hist看清分布再定特征入选。

下面写核心的WOE和IV计算函数:

def calc_woe_iv(df, col, target="target", bins=5): # qcut做等频分箱,duplicates='drop'避免重复边界直接抛异常 df["bin"] = pd.qcut(df[col], bins, duplicates="drop") grouped = df.groupby("bin", observed=True).agg( 好样本=(target, lambda s: (s == 0).sum()), 坏样本=(target, lambda s: (s == 1).sum()), ).reset_index() total_good = grouped["好样本"].sum() total_bad = grouped["坏样本"].sum() grouped["好占比"] = grouped["好样本"] / total_good grouped["坏占比"] = grouped["坏样本"] / total_bad grouped["WOE"] = np.log(grouped["好占比"] / grouped["坏占比"]) # IV = (好占比 - 坏占比) * WOE,各箱相加得到变量总IV grouped["IV"] = (grouped["好占比"] - grouped["坏占比"]) * grouped["WOE"] return grouped woe_age = calc_woe_iv(df, "age", bins=6) print(woe_age[["bin", "好样本", "坏样本", "WOE", "IV"]]) print("age总IV =", woe_age["IV"].sum())

这段代码的逻辑说明:groupby后的agg分别统计每箱好样本数和坏样本数,这里依赖lambda逐一数target等于0和1的行;好占比、坏占比的分母是全局总数而不是箱内坏率,这是WOE定义里最容易搞错的地方。WOE为正当且仅当好占比大于坏占比,说明这一箱比整体更安全。

参数说明:bins=6是初始分箱数,实际按样本量和单调性调节;duplicates="drop"让qcut在重复值过多时不直接抛异常,但它会返回更少的箱,需要确认返回箱数满足预期。另外当某箱坏样本为0时,好占比/坏占比会除零,项目里我会在分母上加一个极小值如1e-6,或者先把空箱剔除再算,不影响整体变量排序。

3.3 变量筛选:IV阈值与单调性检查

WOE和IV算完后,筛选变量有一套约定俗成的规则。IV小于0.02基本不考虑,0.02到0.1算弱预测力,0.1到0.3中等,0.3以上强。但IV过高未必是好信号,大于0.5要警惕数据泄漏或样本选择偏差,比如用"当前是否逾期"预测"未来是否逾期"这种包含结果信息的特征,上线后一定失效。

另一个必须做的是单调性检查。评分卡的加减分逻辑要求单个变量的WOE要么一路上升、要么一路下降。如果出现先升后降再升的波浪形态,说明变量与风险之间不是单调关系,即使IV很高,上线后也容易因人群偏移而失效。常见处理是合并相邻箱让趋势变单调,或者对这个变量重新做分箱。

可以用这段代码快速判断单调性:

def check_monotonic(woe_series): vals = woe_series.tolist() if len(vals) < 3: return True # 记录相邻差值的符号变化次数,不超过1次认为单调 diff = [1 if vals[i] > vals[i-1] else -1 for i in range(1, len(vals))] change = sum(1 for i in range(1, len(diff)) if diff[i] != diff[i-1]) return change <= 1 print(check_monotonic(woe_age["WOE"]))

这个函数只在粗分箱阶段用,正式建模时边界调整后要重新验证单调性。注意它判断的是整体方向变化次数,不关心是递增还是递减,评分卡对两种单调都能接受,只是加减分方向不同。

4. 逻辑回归训练与评分卡分数映射:从系数到一张打分表

4.1 WOE编码与数据划分:先编码还是先划分有讲究

训练前要把每个变量的分箱结果固化成一张编码表,后续训练集、测试集甚至上线的实时数据都走同一套边界。先构建每个变量的WOE映射,再写一个编码函数把原始特征替换成WOE。

def build_woe_map(df, col, target="target", bins=6): # 复用3.2的calc_woe_iv,只保留bin和woe_val两个字段 woe_df = calc_woe_iv(df, col, target, bins) return woe_df[["bin", "woe_val"]] def apply_woe(col, woe_df): # 用训练集生成的边界把原始值映射成woe:先cut成箱,再查woe字典 boundaries = woe_df["bin"].cat.categories binned = pd.cut(col, bins=boundaries) return binned.map(woe_df.set_index("bin")["woe_val"]).fillna(0).values features = ["age", "income", "credit_usage", "debt_ratio"] woe_maps = {f: build_woe_map(df, f, bins=6) for f in features} X = np.column_stack([apply_woe(df[f], woe_maps[f]) for f in features]) y = df["target"].values

代码逻辑说明:build_woe_map先算出每箱的WOE,apply_woe用pd.cut把原始数值映射到同样的箱上,再通过set_index后的映射关系取出对应的WOE值。fillna(0)是对落在分箱边界之外的新样本兜底,用0表示"无额外证据权重",比抛异常稳定。

要点是顺序:必须先只用训练集建边界和WOE,再把这套映射应用到测试集,绝对不能在全部数据上算WOE再划分。否则测试集的分布在编码阶段就泄漏进了训练集,后面评估的AUC和KS全都会虚高。实操里我的习惯是先用train_test_split切出训练集,然后只拿训练集跑calc_woe_iv,测试集只复用边界查表。

4.2 训练逻辑回归:C、class_weight与评估指标

编码完就进入训练环节:

from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split from sklearn.metrics import roc_auc_score X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y ) lr = LogisticRegression( C=1.0, class_weight="balanced", solver="lbfgs", max_iter=1000, ) lr.fit(X_train, y_train) train_auc = roc_auc_score(y_train, lr.predict_proba(X_train)[:, 1]) test_auc = roc_auc_score(y_test, lr.predict_proba(X_test)[:, 1]) print("train AUC:", round(train_auc, 4)) print("test AUC:", round(test_auc, 4)) print("coef:", lr.coef_) print("intercept:", lr.intercept_)

参数说明:class_weight="balanced"让损失函数按样本比例放大坏样本权重,解决坏样本占比过低的偏移问题;C是L2正则强度的倒数,C越小正则越强。如果训练集AUC比测试集高很多,比如差0.1以上,先调小C;如果两个都低,回到分箱阶段增加箱数或换变量。solver用lbfgs是小数据的默认推荐,特征数过千时可以换saga。

评估不能只看AUC。评分卡业务里我更常看KS:对预测概率排序后,累计坏样本占比与累计好样本占比的最大差值,0.3以上算可用的入门模型。AUC衡量整体排序能力,KS更关注风险集中度,两者结合看才能判断分数是否有区分力。

4.3 系数转分数:完整评分映射代码

训练得出系数后,按第2章的A/B公式把系数摊到每一箱。我的写法是:每个变量第j箱的分值 = 常数分摊部分 - B·βi·WOE_j,其中常数部分为(A - B·β0)/变量数。

pdo = 50.0 base_score = 600.0 base_odds = 1 / 19 # 基准odds,坏/好 B = pdo / np.log(2) A = base_score + B * np.log(base_odds) intercept = lr.intercept_[0] n_var = len(features) base_part = (A - B * intercept) / n_var # 常数摊到每个变量 score_card = {} for idx, feat in enumerate(features): beta = lr.coef_[0][idx] woe_df = woe_maps[feat] per_bin = {} for _, row in woe_df.iterrows(): s = base_part - B * beta * row["woe_val"] per_bin[row["bin"]] = round(s, 1) score_card[feat] = per_bin print(score_card["credit_usage"])

这段的关键是把log odds展开成每个箱的线性贡献。公式来源:Score = A - B·(β0 + Σβi·WOE_i) = (A - B·β0) - Σ(B·βi·WOE_i),所以每个变量贡献两部分:平均分摊的常数,以及自己的WOE乘以系数和刻度。beta为负、WOE高于平均时该箱加分,符合"高WOE代表低风险"的业务直觉。

打分函数对一整批样本执行如下:

def score_samples(df, features, score_card, woe_maps): total = pd.Series(0.0, index=df.index) for feat in features: boundaries = woe_maps[feat]["bin"].cat.categories df["_bin"] = pd.cut(df[feat], bins=boundaries) # map的key是Interval对象,pd.cut产生的箱类型一致才能命中 total += df["_bin"].map(score_card[feat]).fillna(0).values return total.round(0) df["score"] = score_samples(df, features, score_card, woe_maps) print(df[["credit_usage", "score"]].head())

注意:map时score_card的key是pd.cut生成的Interval对象,两边类型一致才能匹配成功,这也是为什么不能用箱子字符串做key。生产环境里我会把woe_maps和score_card用pickle或joblib序列化保存,上线服务加载后对新请求逐字段cut再查表,保证分箱边界和线上完全一致。

5. 评分卡落地避坑:5条让我返工的真实踩坑记录

5.1 pd.qcut报"Bin edges must be unique":重复值过多导致分箱失败

现象:对收入这类取值集中的变量做等频分箱,pd.qcut直接抛异常,提示Bin edges must be unique,用duplicates="drop"又发现返回的箱数比预期少。

原因:qcut切分位点时,第p分位和第p+1分位恰好落在同一个值上,产生不了唯一边界。比如收入字段有大量10000这个值,30%的人都是它,6分箱的第2、第3边界全是10000。

解决:先对变量排序后按rank百分比切分,或者更稳妥的做法是改成业务口径粗切,比如收入按0-3k、3k-8k、8k-20k、20k以上这种业务阈值分箱,再对样本量不足的箱手动合并。我不推荐给数据加随机噪声再qcut,噪声会污染边界可解释性。

5.2 训练集AUC高但测试集崩:分箱边界过拟合

现象:某个变量分8箱,训练集AUC 0.82,测试集只有0.56,差距大到没法上线。

原因:初始分箱数过多,每箱样本太少,WOE的估计方差极大。尤其坏样本占比低时,一两个坏样本的变动就能把WOE从1.0拉到-0.3,模型记住的是噪声而不是规律。

解决:把该变量的箱数从8降到4到5,同时要求每箱坏样本不低于30。重新编码后如果趋势不再单调,就做相邻合并。我遇到过某个交叉特征变量训练时IV=0.35,换了一组分箱边界后IV只有0.08,本质就是边界过拟合。

5.3 高IV变量入模后系数符号反转:多重共线性

现象:单变量算IV,某变量0.28,WOE与坏概率负相关很明显;但放进多变量逻辑回归后它的系数变成正的,符号和单变量分析完全相反。

原因:这个变量和其他变量高度相关,比如credit_usage和debt_ratio常常同时偏高,多变量回归把共同的预测力分配给了另一个变量,剩余部分的符号就被翻转了。

解决:入模前先看相关系数矩阵,对相关系数超过0.7的变量保留业务上更稳定、更易解释的一个;再跑一遍单变量LR符号检查,每个变量单独进模型时系数必须是业务直觉方向,符号不对的先处理再用。多重共线性严重时还可以用VIF大于10作为剔除线。

5.4 缺失值直接删行导致样本崩溃:缺失要单独成箱

现象:某变量缺失率30%,数据处理时顺手dropna,结果样本从8000掉到5600,坏样本占比也变了,后面所有IV和系数都不可信。

原因:评分卡的核心优势之一就是能把"缺失"本身当信号。直接删行既浪费信息,又改变了样本分布,后续统计完全失真。

解决:分箱时把NaN单独划为一箱,非缺失部分再qcut,合并统计时缺失组用自己的好占比、坏占比算WOE。如果缺失箱的WOE接近0,说明缺失没有信息量,可以并入邻近正常箱;如果明显偏离0,就保留这一箱并在打分表上给一个缺失惩罚分。这笔血泪经验之后,我再也没在评分卡项目里用过dropna。

5.5 打分出现负分:基准参数和WOE极值的问题

现象:同一套系数按公式算出来的分数有大量负值,客户总分低于300甚至到负数,业务方完全没法接受。

原因:WOE极值过大,比如某箱只有10个坏样本,好占比除以坏占比可以算出WOE等于3甚至更高,乘以B和β后一个箱就扣几百分;或者基准A设得太低,常数分配部分把起始分压到了负数。

解决:一是给WOE做截断,限制在[-2,2]区间,超过的按边界值处理;二是调整模型假设,把基准分从600提到650,或把PDO从50降到35;三是分数出来后做整体平移,保证实际样本最低分落在业务可接受范围。截断WOE要慎重,截断后必须重新验证AUC,不能为了分数好看牺牲判别力。

6. 分数上线前的最后一道关:PSI稳定性检验与cutoff选择

新评分卡上线前,我最看重的是分数分布稳定性。训练集和测试集AUC再漂亮,样本群体三个月后一变,分数整体偏移,cutoff就废了。PSI(Population Stability Index)是用来量化这个偏移的常用指标,小于0.1说明分布稳定,0.1到0.25需要关注,大于0.25说明人群结构已经发生明显变化。

def psi_calc(expected, actual, n_bins=10): # expected是训练集分数,actual是近期线上分数 cuts = pd.qcut(expected, n_bins, duplicates="drop") exp_rate = expected.groupby(cuts).size() / len(expected) cuts = pd.qcut(expected, n_bins, duplicates="drop") # 用同一组边界切actual act_rate = actual.groupby(cuts).size() / len(actual) act_rate = act_rate.reindex(exp_rate.index, fill_value=0) # 占比为0时加极小值避免除零 psi = ((act_rate - exp_rate) * np.log((act_rate + 1e-6) / (exp_rate + 1e-6))).sum() return psi

这段代码先用训练集分数分位数定边界,再用同一组边界切线上数据,计算各段占比偏移。顺序不能反,否则两个分布各自分箱后段位对不上。实际项目中我会取最近三个月的离线数据回放打分,分别算PSI,任何一个月份的偏移超过0.1就要回查分箱边界和特征稳定性。

cutoff的选择要看业务承受能力,一般把测试集分数排序后按不同阈值计算通过率和坏账率,常用的一张对比表如下:

分数阈值通过率估算坏账率适用场景
45085%6.2%扩张期,容忍更高违约
55068%4.1%稳健经营
60052%2.8%保守,控制风险

上表的数字是示意,每个项目的实际坏账率要用测试集真实标签去算。我现在的习惯是:cutoff定完后必须看看阈值附近的分数分布是否平滑,如果600分的人特别多、cutoff正好切在人堆中间,运营稍微放松一点就会涌进来一大批人,这时候宁可把cutoff下探或上移避开尖峰。这套流程帮我挡掉过不止一次因为人群结构变化导致的模型失效。评分卡做到这个程度,才算真正能交给业务用的东西,希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询