多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

基于Python的逻辑回归评分卡模型构建全解析

基于Python的逻辑回归评分卡模型构建全解析 简介基于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(y1|X)也就是申请人在给定特征下的违约概率。这个概率由线性组合z β0 β1x1 ... βnxn 通过sigmoid函数映射得到P 1 / (1 e^(-z))把上式变形得到ln(P/(1-P)) z左边是对数几率也叫log odds。这一步变形是整个评分卡的起点——信用分数恰好定义在log odds的线性函数上所以逻辑回归输出的z天然对应分数的坐标轴不需要额外做概率到分数的非线性转换。训练逻辑回归时常用交叉熵损失而不是回归任务里惯用的均方误差。原因是MSE配sigmoid会把损失函数变成非凸函数梯度下降容易卡在局部最优点交叉熵的负对数似然在逻辑回归下是凸优化用lbfgs或牛顿法能稳定收敛。实际调参时有个容易忽略的点损失函数里y1和y0两项默认权重相等但如果坏样本占比只有5%甚至更低模型会整体往全部判好偏移。评分卡建模因此离不开class_weightbalanced或手动设置坏样本权重这个参数会直接影响后面所有特征的系数具体设置在章节4给出。2.2 分数映射公式A、B两个参数怎么定有了逻辑回归的系数下一步是把log odds换算成业务能懂的信用分数。评分卡的标准映射是Score A - B · ln(odds)这里的odds定义为坏概率/好概率。用减号的意义很直观odds越高代表风险越高分数必须越低。A是基准分B是刻度要确定这两个数业界惯例是给两个业务假设某个基准odds对应一个基准分。比如odds 1:19对应约5%的坏客户率给定分数600。odds翻倍时分数下降PDO分。比如PDO50表示odds从1:19变成2:19时分数从600降到550。由条件2可得 B PDO / ln2。以PDO50算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 分箱方法对比等频、等距与自动分箱怎么选分箱是评分卡建模里最手工也最影响结果的一步常见三种做法等频分箱按样本量等分用pd.qcut实现。优点是每箱样本量接近后续WOE估计方差小缺点是大量重复值时候容易报错边界不一定是业务可解释的整数。适合连续变量首轮粗分。等距分箱按数值区间等宽划分。边界好解释但样本分布不均长尾变量会出现空箱或极少数样本箱导致WOE不稳定。自动分箱卡方分箱和决策树分箱把相邻箱按目标分布相似性合并能做自动的最优分箱。卡方分箱需要自己写合并逻辑代码量不小决策树可以用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, targettarget, bins5): # qcut做等频分箱duplicatesdrop避免重复边界直接抛异常 df[bin] pd.qcut(df[col], bins, duplicatesdrop) grouped df.groupby(bin, observedTrue).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, bins6) print(woe_age[[bin, 好样本, 坏样本, WOE, IV]]) print(age总IV , woe_age[IV].sum())这段代码的逻辑说明groupby后的agg分别统计每箱好样本数和坏样本数这里依赖lambda逐一数target等于0和1的行好占比、坏占比的分母是全局总数而不是箱内坏率这是WOE定义里最容易搞错的地方。WOE为正当且仅当好占比大于坏占比说明这一箱比整体更安全。参数说明bins6是初始分箱数实际按样本量和单调性调节duplicatesdrop让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, targettarget, bins6): # 复用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, binsboundaries) 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, bins6) 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先算出每箱的WOEapply_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_size0.3, random_state42, stratifyy ) lr LogisticRegression( C1.0, class_weightbalanced, solverlbfgs, max_iter1000, ) 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_weightbalanced让损失函数按样本比例放大坏样本权重解决坏样本占比过低的偏移问题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, indexdf.index) for feat in features: boundaries woe_maps[feat][bin].cat.categories df[_bin] pd.cut(df[feat], binsboundaries) # 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用duplicatesdrop又发现返回的箱数比预期少。原因qcut切分位点时第p分位和第p1分位恰好落在同一个值上产生不了唯一边界。比如收入字段有大量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。重新编码后如果趋势不再单调就做相邻合并。我遇到过某个交叉特征变量训练时IV0.35换了一组分箱边界后IV只有0.08本质就是边界过拟合。5.3 高IV变量入模后系数符号反转多重共线性现象单变量算IV某变量0.28WOE与坏概率负相关很明显但放进多变量逻辑回归后它的系数变成正的符号和单变量分析完全相反。原因这个变量和其他变量高度相关比如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就废了。PSIPopulation Stability Index是用来量化这个偏移的常用指标小于0.1说明分布稳定0.1到0.25需要关注大于0.25说明人群结构已经发生明显变化。def psi_calc(expected, actual, n_bins10): # expected是训练集分数actual是近期线上分数 cuts pd.qcut(expected, n_bins, duplicatesdrop) exp_rate expected.groupby(cuts).size() / len(expected) cuts pd.qcut(expected, n_bins, duplicatesdrop) # 用同一组边界切actual act_rate actual.groupby(cuts).size() / len(actual) act_rate act_rate.reindex(exp_rate.index, fill_value0) # 占比为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下探或上移避开尖峰。这套流程帮我挡掉过不止一次因为人群结构变化导致的模型失效。评分卡做到这个程度才算真正能交给业务用的东西希望帮到你。本文还有配套的精品资源点击获取
返回列表