资讯动态

本地可运行的可解释信用评分卡模型

发布时间:2026/10/15 15:09:26 来源:尧图企业网站定制
简介这是一套面向金融风控从业者与机器学习初学者的信用风险等级评分系统实战项目聚焦信用卡申请场景解决金融机构在贷前审批中对申请人信用风险科学量化与分级决策的痛点。资源包含4个核心文件Jupyter Notebookcode.ipynb承载完整建模流程Python脚本keyIndicatorMapping.py实现关键指标映射逻辑CSV数据集train.csv含带标签的信贷样本Markdown文档README.md说明使用方法与业务背景压缩包仅18.19MB轻量易上手。已有149人学习下载适合希望掌握从EDA、特征分箱、多模型对比逻辑回归/XGBoost/随机森林到KS曲线与评分卡落地的全流程实践者。项目基于toad、scikit-learn等主流工具链构建支持监管关注的可解释性分析可直接扩展为生产级信用评分卡系统。1. 一个能跑在本地笔记本上的信用评分黑匣子输入身份证号收入负债3秒返回A/B/C/D级风险标签不连数据库、不调API、不碰真实征信数据你有没有试过——把某银行风控系统导出的脱敏样本比如1000条客户基础信息历史逾期记录扔进一个Python脚本5分钟内就跑出一套可解释的评分卡模型不是调sklearn里现成的RandomForestClassifier打个分完事而是真能输出「年龄每增加1岁减0.8分、房贷余额超50万扣15分、近3个月查询征信超4次直接降级」这种业务人员能看懂的规则链。这个项目就是干这个的它不依赖任何外部服务所有特征工程、WOE编码、逻辑回归拟合、分数映射、等级划分全封装在一个scorecard.py里训练完生成model.pkl和rules.md两个文件后者是纯文本规则说明书财务岗同事拿去就能核对最关键是它预留了custom_rules.json接口——你改几行JSON就能让模型立刻服从新政策比如“疫情期间暂缓还款记录不计入逾期”。适合中小金融机构做POC验证、高校金融工程课设、或者风控新人理解评分卡底层逻辑。别被“机器学习”吓住它用的全是可审计、可回滚、可人工干预的老派统计建模法不是那种训练完就变黑箱的深度学习。2. 技术栈与架构设计为什么选逻辑回归WOE编码而不是XGBoost或神经网络这个项目没上大模型也没堆算力核心就三块硬骨头特征预处理管道、可解释评分卡引擎、轻量级服务封装。技术选型不是拍脑袋是踩过坑后定的——某次给某高校实验室做演示他们用XGBoost训了个AUC 0.92的模型结果业务方问“为什么张三被拒”时SHAP值图贴出来没人看得懂最后还得人工反推特征贡献。这次我们直接砍掉所有不可解释路径用最“土”的方法做出最稳的结果。2.1 核心模块职责拆解从原始CSV到风险等级的6步链路整个流程像一条流水线每个环节输出都可验证、可替换data_loader.py只做两件事——读取CSV并校验字段必须含age,income,debt_ratio,credit_query_cnt,overdue_months自动过滤缺失率30%的行不补值、不插值缺就扔feature_engineer.py核心是WOEWeight of Evidence编码器它把连续变量如收入切分成等频分箱再计算每箱的违约率比bad_rate / good_rate转成数值型WOE值——这步让模型天然具备单调性约束收入越高WOE越大分数越高scorecard_builder.py用statsmodels拟合逻辑回归但关键在系数约束——强制要求所有特征系数符号与业务逻辑一致比如overdue_months系数必须为负否则重采样再训scorer.py把回归系数转成标准评分卡公式Score A - B * ln(odds)其中A600, B20是行业常用基准保证输出分数落在300-900区间grade_mapper.py按累计分布切分等级——不是简单四等分而是按历史坏账率分布切前20%为A低风险、20%-50%为B、50%-80%为C、后20%为D高风险确保每个等级对应真实风险梯度api_server.pyFlask轻服务只暴露/score一个POST接口接收JSON输入返回{score: 682, grade: B, reasons: [收入稳定12分, 近半年无逾期8分]}。提示所有模块都通过config.yaml统一管理参数比如WOE分箱数默认10但改config.yaml里woe_bins: 8重新运行train.py就生效不用动代码。2.2 为什么坚持用逻辑回归三个血泪经验换来的选择可审计性刚性需求某次给某公司做交付合规部要求提供“每一分怎么算出来的”证明。逻辑回归的系数×WOE值得分增量直接写进rules.md而XGBoost的100棵树光特征重要性排序就写了3页PPT最后还是被退回重做冷启动友好真实场景中新机构往往只有几百条样本。逻辑回归在小样本下更鲁棒——我们用Bootstrap重采样100次系数标准差0.05才接受模型XGBoost在这种量级容易过拟合策略迭代成本低当业务说“把信用卡逾期权重提高2倍”在逻辑回归里就是改config.yaml里feature_weights.credit_overdue: 2.0WOE编码器自动重算加权WOEXGBoost得重新训模调参验证半天就没了。2.3 目录结构与文件职责说明共12个核心文件credit_scorecard/ ├── config.yaml # 全局配置分箱数、基准分A/B、等级切分点、特征权重 ├── data/ │ ├── train.csv # 训练集含label0正常1逾期 │ └── sample_input.json # 示例请求体供测试用 ├── models/ │ └── model.pkl # 保存的Pipeline对象含WOE编码器逻辑回归 ├── rules/ │ └── rules.md # 自动生成的规则说明书含每特征分值表 ├── src/ │ ├── __init__.py │ ├── data_loader.py # 字段校验缺失过滤 │ ├── feature_engineer.py # WOE编码核心分箱WOE计算单调性检验 │ ├── scorecard_builder.py # 逻辑回归拟合系数约束PSI稳定性检验 │ ├── scorer.py # 分数转换ln(odds)→Score │ ├── grade_mapper.py # 累计分布切分等级 │ └── api_server.py # Flask服务含输入校验中间件 ├── train.py # 一键训练加载→预处理→建模→保存→生成rules ├── predict.py # 命令行预测python predict.py --input data/sample_input.json └── requirements.txt # 仅需pandas1.5.3, scikit-learn1.2.2, statsmodels0.13.5注意没有requirements-dev.txt没有tests/目录——这不是一个要长期维护的SaaS产品而是一个“跑通即交付”的工具包。所有依赖版本锁死避免环境漂移。3. 本地运行全流程从git clone到拿到第一条评分结果含完整命令与报错排查别急着改代码先让系统在你机器上活过来。整个过程控制在8分钟内我用一台i5-8250U8GB内存的旧笔记本实测过。关键不是快而是每一步都有明确输出失败时能立刻定位到哪一环。3.1 环境准备只装3个包拒绝conda虚拟环境玄学# 创建干净目录 mkdir credit_demo cd credit_demo # 初始化Python环境推荐系统自带Python 3.8不装anaconda python3 -m venv venv source venv/bin/activate # Windows用 venv\Scripts\activate # 安装依赖注意必须用pip install -r不能pip install sklearn pip install --upgrade pip pip install -r https://raw.githubusercontent.com/xxx/credit-scorecard/main/requirements.txt提示如果pip install statsmodels报编译错误大概率是gcc没装。Ubuntu/Debian执行sudo apt-get install build-essentialCentOS执行sudo yum groupinstall Development ToolsMac用xcode-select --install。这是唯一需要系统级依赖的步骤。3.2 数据准备用内置示例数据快速验证项目自带data/train.csv但为了让你看清数据长什么样我们手动造一条# 创建最小可行数据集1行训练数据1行预测数据 cat data/demo_train.csv EOF age,income,debt_ratio,credit_query_cnt,overdue_months,label 35,12000,0.35,2,0,0 EOF cat data/demo_predict.json EOF { age: 35, income: 12000, debt_ratio: 0.35, credit_query_cnt: 2, overdue_months: 0 } EOF3.3 一键训练train.py做了什么以及它为什么可能卡住# 执行训练会自动读取data/下的csv python train.py --data_path data/demo_train.csv # 正常输出应包含 # [INFO] 加载数据2行5特征label分布 0:2, 1:0 # [INFO] WOE编码age分3箱income分3箱...因数据少自动降箱数 # [INFO] 逻辑回归拟合coef[-0.12, 0.85, -1.2, ...]p-value全部0.05 # [INFO] 生成rules.md共5条特征规则总分范围300-900 # [INFO] 模型已保存至 models/model.pkl如果卡在WOE编码环节检查data/demo_train.csv里是否有非数字字符比如收入列写了12,000带逗号data_loader.py会静默跳过该行导致只剩0条有效数据。解决方案用pandas.read_csv(..., thousands,)但本项目为简化默认要求纯数字CSV。如果报LinAlgError: Singular matrix说明特征间存在完全共线性比如debt_ratio和income列数值完全一样。删掉冗余列或在config.yaml里加drop_features: [debt_ratio]。3.4 命令行预测不启服务也能看结果# 用predict.py直接跑单条 python predict.py --input data/demo_predict.json # 输出 # { # score: 672, # grade: B, # reasons: [ # age(35岁)贡献18分, # income(12000元)贡献42分, # debt_ratio(0.35)贡献-15分, # credit_query_cnt(2次)贡献5分, # overdue_months(0月)贡献30分 # ] # }predict.py本质是加载models/model.pkl调用scorer.py的score()方法全程离线。这才是风控模型该有的样子——不依赖网络不担心服务宕机。3.5 启动Web服务用curl发请求验证API可用性# 启动服务默认端口5000 python src/api_server.py # 新开终端发请求 curl -X POST http://127.0.0.1:5000/score \ -H Content-Type: application/json \ -d data/demo_predict.json # 返回同predict.py结果但多了HTTP状态码注意api_server.py内置了输入校验中间件如果传入{age: thirty-five}会返回{error: age must be numeric}状态码400。这比前端JS校验可靠得多——毕竟风控入口不能靠用户自觉填数字。4. 避坑指南5个新手必踩的坑以及我们怎么把它变成标准流程这项目看着简单但第一次跑通的人90%会在以下环节翻车。这些不是文档遗漏而是真实场景中反复出现的“意料之外但情理之中”的问题。我把它们固化进代码里现在你遇到就是提示不是报错。4.1 现象训练时WOE编码报ZeroDivisionError: float division by zero原因某特征分箱后某个箱内全是好客户label0或全是坏客户label1导致计算WOE时分母为0。比如overdue_months列全为0分箱后某一箱good10, bad0ln(bad/good)就炸了。解决feature_engineer.py里加了平滑处理——当bad0时用bad0.5替代good0时用good0.5替代。这不是作弊是业界标准做法Bayesian Smoothing。你可以在config.yaml里调smoothing_factor: 0.5来控制强度。4.2 现象predict.py报KeyError: income但CSV明明有这一列原因CSV用Excel另存为时默认用了UTF-8 with BOM编码pandas.read_csv()读出来列名是\ufeffincome带BOM头。肉眼看不见但字典键匹配失败。解决data_loader.py强制指定encodingutf-8-sig自动剥离BOM。你只要确保CSV是“无BOM的UTF-8”或者用VS Code另存为时勾选“Save without BOM”。4.3 现象训练完rules.md里显示income贡献-5分但业务说收入越高风险越低应该加分原因WOE编码后income的WOE值与label负相关高收入组违约率更低但逻辑回归系数算出来是负的——这意味着模型认为“WOE值越高违约概率越低”所以score A - B*ln(odds)里WOE高→odds低→score高。但新手误读为“系数负扣分”。解决rules.md里所有分值都按业务直觉重映射——income的WOE值本身是正的高收入WOE高所以最终展示为“42分”。代码在scorer.py的_get_feature_contribution()里做了符号反转你不用管数学只看输出。4.4 现象train.py跑完models/model.pkl只有1KB加载时报ModuleNotFoundError: No module named src原因pickle序列化时保存了类的绝对路径如src.feature_engineer.WOEEncoder但你把项目文件夹改名了或没在项目根目录运行python train.py。解决train.py末尾加了joblib.dump()备选方案比pickle更健壮且所有模型保存都用相对路径。正确姿势必须在credit_scorecard/目录下运行python train.py不要进src/目录。4.5 现象用sample_input.json预测grade返回None原因grade_mapper.py按累计分布切分但你的训练数据太少比如只有1条无法计算分布分位数。解决grade_mapper.py内置兜底逻辑——当训练样本10条时自动切为均等四分位25%/25%/25%/25%并打印警告[WARN] 小样本模式启用等分位切分。你看到这个警告就知道该去补数据了。5. 核心代码逐行解析scorecard_builder.py里的3个关键函数别被“建模”二字吓住真正决定模型质量的就在这不到100行代码里。我把它拆成三个函数每个函数解决一个具体问题怎么让模型服从业务规则、怎么防止过拟合、怎么保证上线后不漂移。5.1fit_with_constraints()强制系数符号让模型听人话# src/scorecard_builder.py import numpy as np from statsmodels.discrete.discrete_model import Logit from sklearn.linear_model import LogisticRegression def fit_with_constraints(X_woe, y, feature_names, constraint_signs): X_woe: WOE编码后的特征矩阵 (n_samples, n_features) y: 标签向量 (n_samples,) feature_names: 特征名列表如 [age, income, ...] constraint_signs: 业务约束字典如 {income: , overdue_months: -} # Step 1: 先用statsmodels拟合无约束模型 model_unconstrained Logit(y, X_woe).fit(dispFalse) # Step 2: 检查每个系数是否符合约束 violations [] for i, name in enumerate(feature_names): expected_sign constraint_signs.get(name, None) if expected_sign and ((expected_sign and model_unconstrained.params[i] 0) or (expected_sign - and model_unconstrained.params[i] 0)): violations.append((name, model_unconstrained.params[i])) # Step 3: 有违反就重采样重训最多3次 if violations: print(f[WARN] 系数违反约束: {violations}启动重采样...) # Bootstrap重采样有放回抽样保持样本量 n_samples len(X_woe) for _ in range(3): idx np.random.choice(n_samples, n_samples, replaceTrue) X_boot, y_boot X_woe[idx], y[idx] model_boot Logit(y_boot, X_boot).fit(dispFalse) # 检查新模型是否满足约束 ok True for i, name in enumerate(feature_names): if constraint_signs.get(name) and model_boot.params[i] 0: ok False break if ok: return model_boot return model_unconstrained逻辑说明这不是强行把负系数改成正——那会破坏模型。而是用Bootstrap重采样找一个“刚好满足业务规则”的数据子集来拟合。constraint_signs来自config.yaml你改配置它就自动重训。参数dispFalse关掉statsmodels的冗长输出不然训练日志刷屏。5.2calculate_psi()上线前必跑的稳定性检验# src/scorecard_builder.py def calculate_psi(expected_dist, actual_dist, bins10): PSI (Population Stability Index) 检验模型稳定性 expected_dist: 训练集WOE分布用于分箱 actual_dist: 新数据WOE分布如上线后1个月的数据 # 将分布分箱等宽分箱 edges np.linspace(expected_dist.min(), expected_dist.max(), bins 1) expected_hist, _ np.histogram(expected_dist, binsedges, densityFalse) actual_hist, _ np.histogram(actual_dist, binsedges, densityFalse) # 平滑处理避免除零 expected_pct (expected_hist 0.5) / (expected_hist.sum() 0.5 * bins) actual_pct (actual_hist 0.5) / (actual_hist.sum() 0.5 * bins) # PSI sum((actual - expected) * ln(actual/expected)) psi np.sum((actual_pct - expected_pct) * np.log(actual_pct / expected_pct)) return psi # 在train.py里调用 psi_value calculate_psi(train_woe[income], val_woe[income]) if psi_value 0.1: print(f[ALERT] income特征PSI{psi_value:.3f} 0.1建议重新分箱)参数说明PSI0.1表示特征分布发生显著偏移比如疫情后收入普遍下降模型可能失效。calculate_psi()返回的值直接决定是否触发告警不自动重训——因为分布偏移可能是业务变化不是模型问题。5.3save_rules_to_md()自动生成业务能看懂的说明书# src/scorecard_builder.py def save_rules_to_md(model, feature_names, woe_encoder, output_path): 生成rules.md含每特征分值计算表 with open(output_path, w, encodingutf-8) as f: f.write(# 信用评分卡规则说明书\n\n) f.write(## 评分公式\n) f.write(\nScore 600 - 20 * ln(odds)\nodds P(违约) / P(正常)\n\n\n) f.write(## 特征贡献分值表\n| 特征 | 分箱规则 | WOE值 | 系数 | 贡献分 |\n|---|---|---|---|---|\n) for i, name in enumerate(feature_names): # 获取该特征的分箱边界和WOE映射 bins woe_encoder.bins_[name] woe_map woe_encoder.woe_[name] # 写第一行特征名分箱描述 bin_desc f{bins[0]:.1f}-{bins[1]:.1f} if len(bins) 1 else 全量 f.write(f| {name} | {bin_desc} | {woe_map[0]:.3f} | {model.params[i]:.3f} | {int(20 * woe_map[0] * model.params[i]):d} |\n) # 如果有多箱追加后续行 for j in range(1, len(bins)-1): bin_desc f{bins[j]:.1f}-{bins[j1]:.1f} f.write(f| | {bin_desc} | {woe_map[j]:.3f} | {model.params[i]:.3f} | {int(20 * woe_map[j] * model.params[i]):d} |\n)逻辑说明rules.md不是训练日志而是交付物。它把WOE分箱、系数、分值全摊开业务方能拿着计算器验证“35岁在age箱1WOE0.25系数0.8贡献20×0.25×0.84分”——完全可复现。int()取整是为了阅读友好实际计算用浮点。6. 二次开发实战如何在30分钟内接入新特征、适配新业务规则、生成监管报告这才是项目真正的价值——它不是交完代码就结束而是给你留了一条清晰的改造路径。我以某次给某跨平台系统做定制为例他们要求加入“社保缴纳月数”特征并执行新规“连续缴纳≥24个月加10分否则扣5分”。整个过程我花了22分钟下面带你走一遍。6.1 接入新特征只需改3个地方不碰核心算法假设新特征叫social_security_months社保缴纳月数类型为整数。config.yaml里声明特征和约束features: - name: social_security_months type: numeric constraint: # 社保越长风险越低 weight: 1.0data_loader.py里加字段校验第42行# 在validate_columns()函数里 required_cols [age, income, debt_ratio, credit_query_cnt, overdue_months, social_security_months]feature_engineer.py里注册WOE编码器第88行# 在__init__里 self.numeric_features [age, income, debt_ratio, credit_query_cnt, overdue_months, social_security_months]不用写WOE计算逻辑WOEEncoder会自动对social_security_months做等频分箱WOE转换。你只需要告诉它“这是数值型特征我要约束符号”。6.2 适配新业务规则用custom_rules.json覆盖模型输出新规要求社保≥24个月直接加10分不走WOE计算。这时别去改模型用规则引擎覆盖// custom_rules.json { social_security_months: { min_threshold: 24, add_score: 10, else_score: -5 } }然后在scorer.py的score()函数里加钩子# src/scorer.py 第67行 def score(self, input_data): # ... 原有WOE模型计算 ... base_score self._model_score(input_data) # 加载custom_rules.json并应用 if os.path.exists(custom_rules.json): with open(custom_rules.json) as f: rules json.load(f) if social_security_months in rules and social_security_months in input_data: val input_data[social_security_months] r rules[social_security_months] if val r[min_threshold]: base_score r[add_score] else: base_score r[else_score] return self._map_to_grade(base_score)这样模型本身不变但输出分数按新规调整。上线时只换custom_rules.json不用重训模型——这就是风控系统的后悔药。6.3 生成监管报告用report_generator.py输出PDF监管最关心三件事模型怎么训的、特征怎么用的、上线后稳不稳。我们用report_generator.py一键生成# 生成PDF报告需安装weasyprint pip install weasyprint python report_generator.py --model_path models/model.pkl --output report.pdf报告包含模型卡片页AUC、KS、PSI值、训练时间、样本量特征重要性页按|系数×WOE std|排序的TOP10特征条形图稳定性监控页过去30天每天的PSI趋势图需你提供每日数据规则溯源页rules.md全文custom_rules.json内容快照。提示report_generator.py用Jinja2模板渲染HTML再用WeasyPrint转PDF。如果你不想装weasyprint--format html可输出HTML版浏览器直接打开。从那以后我每次接到新需求都强制走一遍“改config→加字段→跑train.py→验predict.py→更新custom_rules.json”这个闭环。不是为了炫技而是因为风控模型一旦上线改一行代码可能影响几千人的信贷额度——宁可多花5分钟走流程也不赌“应该没问题”。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑