
简介一份面向机器学习与数据分析初学者的逻辑回归实战项目适合作为课程设计、毕业设计或工程实训的入门选题。项目围绕银行信贷中的违约预测场景使用Python实现二分类建模完整覆盖数据读取、数据探索、特征处理、逻辑回归训练、模型预测与效果评估等核心环节有助于理解逻辑回归的原理、参数含义及实际业务中的应用方式。压缩包共3个文件包含一个Python脚本、一个CSV格式的样本数据集以及一份README说明文档整体大小仅19KB体量精简便于快速下载和本地调试。目前已有203人学习下载。借助配套的说明文档可快速掌握运行环境与代码逻辑自带数据集可直接用于实验复现和参数调优整体代码结构简洁适合作为模板扩展到其他分类预测任务帮助读者用项目驱动方式入门逻辑回归。1. 逻辑回归为什么还是违约预测的主力模型信贷风控领域有个反直觉的现象深度学习模型在图像和 NLP 上攻城略地但到了违约风险预测这种表格数据场景逻辑回归依然是很多生产系统的首选。原因不是架构保守而是违约预测的本质要求——可解释性、稳定性和监管合规。银行或金融机构在拒掉一笔贷款申请时必须能说清楚“为什么拒绝”逻辑回归的权重系数直接给出了每个特征的边际贡献这让模型天然具备审计能力。更重要的是逻辑回归的预测输出是一个介于 0 到 1 之间的概率值恰好对应“违约可能性”这个业务指标。配合评分卡转换公式概率可以直接映射成整数分数便于业务人员设定审批阈值。如果你的目标是预测“某位客户在未来 12 个月内违约的概率有多大”逻辑回归不是最花哨的方案却是从建模到上线链路最短、排错成本最低的方案。这篇文章按照我自己做信贷评分模型时的顺序来写数据准备和特征处理、模型训练与参数调整、评估指标的选取、最后落到实时评分场景里的工程细节。读者最好有 Python 基础熟悉 pandas 和 sklearn 的基本用法但即使你只是刚装好 Python 环境也可以跟着每一步跑通因为这个流程对数据量的要求不高重点在思路和参数取舍。2. 违约预测的数据准备从原始信贷数据到可训练样本2.1 违约标签的定义与样本切分逻辑回归是监督学习算法第一步必须明确“什么算违约”。常见定义是逾期超过 90 天或 180 天未还款标记为 1其余为 0。这个口径直接影响模型学习的目标务必与业务方对齐。一个典型的反直觉点直接用当前时间点的欠款状态做标签会造成幸存者偏差。比如你今天拉数据张三 3 年前借的款已经还清状态是正常李四 1 年前借款最近刚逾期 30 天。如果直接打标签张三被记为 0李四被记为 0还没到 90 天口径模型就学不到“李四可能正在走向违约”的信号。我一般会用观察点和表现点的方式以某个历史日期为观察点只取在那之前已放款的客户然后看观察点之后 12 个月内是否发生逾期 90 天以上的事件。2.1.1 数据集划分的三种常见方式拿到带标签的样本后要做的是把数据切成训练集、验证集和测试集。纯随机切分最简单但违约样本通常占比很低5% 以下随机切分可能导致验证集里违约样本太少评估结果波动很大。from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.3, random_state42, stratifyy # 保持训练集和测试集中违约比例一致 ) print(f训练集违约率: {y_train.mean():.4f}) print(f测试集违约率: {y_test.mean():.4f})stratifyy参数强制切分后两个集合的标签分布与原数据一致这是违约预测这类不平衡场景里最容易忽视的第一步。如果测试集里只有 2 个违约样本后面算出来的 AUC 和 KS 都没参考价值。除了随机切分实际操作里还要考虑时间切分训练集取 2022 年以前的数据验证集取 2022 年测试集取 2023 年。因为违约行为受宏观经济和政策影响模型在未来数据上的表现才是真实水平。我通常两种都做随机切分用来调参时间切分用来做最终评估。2.2 特征工程WOE 编码与分箱的实际做法逻辑回归的输入特征是数值或离散编码后的变量但原始信贷特征大多是类别型和偏态分布直接灌进模型会出问题。比如“年龄”这个变量25 岁和 80 岁的人数分布完全不对称逻辑回归的线性假设会被极端值拉偏。信贷风控里最经典的编码方式是 WOEWeight of Evidence证据权重它衡量的是“这个特征取值下好客户和坏客户的分布差异”。WOE 的计算公式是WOE_i ln(坏客户占比 / 好客户占比)编码前需要先对连续变量分箱。分箱的目的不是简单离散化而是把特征对违约率的单调性或非线性关系暴露出来让逻辑回归更容易拟合。import pandas as pd import numpy as np def woe_encode(df, feature, target, bins10): 对连续特征分箱后计算WOE返回编码后的特征列 # 对特征分箱用pd.qcut按分位数切分保证每箱样本量相近 df[bin] pd.qcut(df[feature], qbins, duplicatesdrop) # 统计每个箱内的好客户数target0和坏客户数target1 grouped df.groupby(bin, observedTrue).agg( good(target, lambda x: (x 0).sum()), bad(target, lambda x: (x 1).sum()) ).reset_index() # 计算每个箱的坏客户占比分母加0.5是为了避免除零 grouped[bad_rate] (grouped[bad] 0.5) / (grouped[good] grouped[bad] 1) grouped[woe] np.log(grouped[bad_rate] / (1 - grouped[bad_rate])) # 把WOE值映射回原始样本 df[woe_ feature] df[bin].map(grouped.set_index(bin)[woe]) return df[woe_ feature]逻辑说明pd.qcut按分位数切分能保证每个分箱里有足够的样本量避免某些箱只有个位数样本导致 WOE 计算不稳定。加 0.5 的平滑处理叫拉普拉斯平滑是为了避免某箱坏客户数为 0 时取对数出现无穷大。用woe_前缀命名新特征是为了方便后面回溯每个特征对应的原始分箱区间。这里要留意一个常见误用WOE 编码后再用StandardScaler做标准化。WOE 本身已经是类似对数的尺度取值范围通常在 -2 到 2 之间再做标准化不会增加信息量反而增加了上线时的计算步骤。我一般对 WOE 特征直接建模不对其做标准化。2.3 特征筛选为什么不直接用全部特征逻辑回归对特征间的相关性敏感特征多了不仅训练变慢还会放大共线性导致权重符号和业务直觉相反。比如“负债率”和“月还款额”高度相关模型可能会给其中一个正权重、另一个负权重这在风控评审时很难解释。筛选特征我使用 IVInformation Value信息价值作为粗筛指标IV 的计算基于 WOEIV Σ (坏客户占比 - 好客户占比) × WOEdef calculate_iv(df, feature, target): 基于WOE计算IV值用于衡量特征区分度 temp df[[feature, target]].copy() temp[bin] pd.qcut(temp[feature], q10, duplicatesdrop) grouped temp.groupby(bin, observedTrue).agg( good(target, lambda x: (x 0).sum()), bad(target, lambda x: (x 1).sum()) ) grouped[good_pct] grouped[good] / grouped[good].sum() grouped[bad_pct] grouped[bad] / grouped[bad].sum() grouped[woe] np.log(grouped[bad_pct] / grouped[good_pct]) grouped[iv] (grouped[bad_pct] - grouped[good_pct]) * grouped[woe] return grouped[iv].sum() features [age, income, debt_ratio, loan_amount, credit_history] for f in features: iv calculate_iv(df, f, target) print(f{f}: IV {iv:.4f})IV 的一般判断标准小于 0.02 基本没有预测力0.02 到 0.1 较弱0.1 到 0.3 中等大于 0.3 较强但也要警惕过拟合。实操里我不只依赖 IV还会看特征覆盖率。信贷数据里很多字段缺失率超过 40%这类特征即使 IV 很高上线后也会因为缺失值处理引入不稳定因素我会优先放弃。筛选完特征后训练集和测试集要使用同样的分箱边界完成 WOE 编码否则模型上线后新数据的特征分布会和训练时不一致。分箱边界的保存可以使用joblib.dump把pd.qcut的边界数组存下来推理时直接复用。3. sklearn 训练逻辑回归模型参数、调参与判别阈值3.1 在 scikit-learn 1.5.x 上跑通最小训练代码环境上我建议用 Python 3.10 以上的版本并安装 scikit-learn 1.5.x这个版本的LogisticRegression默认求解器和参数行为比较稳定可以避免很多网上老教程里因为版本差异带来的报错。最小可运行代码如下from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score, precision_recall_curve import joblib # X_train_woe 是经过WOE编码后的特征矩阵y_train 是0/1标签 model LogisticRegression( penaltyl2, C1.0, solverlbfgs, max_iter1000, class_weightbalanced ) model.fit(X_train_woe, y_train) # 预测违约概率 y_pred_proba model.predict_proba(X_test_woe)[:, 1] print(f测试集AUC: {roc_auc_score(y_test, y_pred_proba):.4f}) print(f特征权重: {dict(zip(feature_names, model.coef_[0]))}) # 保存模型和分箱边界后续上线用 joblib.dump(model, lr_model.pkl)class_weightbalanced意味着根据样本比例自动放大少数类违约样本的权重解决类别不平衡问题。C1.0是正则化强度的倒数数值越小正则化越强权重越趋近于 0能抑制过拟合。lbfgs求解器在中小规模数据集上表现稳定它使用梯度信息迭代求解损失函数的最小值相比liblinear每次迭代要遍历全部样本lbfgs的收敛速度通常更快对特征维度几十到几百的表格数据非常适合。3.2 正则化参数 C 与 solver 的取舍C 是逻辑回归最需要手调的参数选不好模型要么欠拟合要么过拟合。我采用网格搜索加交叉验证的方式确定 C 的取值但网格搜索的评估指标不是准确率而是 AUC因为准确率在不平衡数据上完全失效——就算模型把所有样本都预测为不违约准确率也可能高达 95%。from sklearn.model_selection import GridSearchCV from sklearn.model_selection import StratifiedKFold param_grid {C: [0.01, 0.1, 1, 10, 100]} cv StratifiedKFold(n_splits5, shuffleTrue, random_state42) # scoringroc_auc 让交叉验证每次都用AUC评估模型 grid GridSearchCV( LogisticRegression(penaltyl2, solverlbfgs, max_iter1000), param_gridparam_grid, scoringroc_auc, cvcv, n_jobs-1 ) grid.fit(X_train_woe, y_train) print(f最优C: {grid.best_params_[C]}) print(f交叉验证AUC: {grid.best_score_:.4f}) # 用最优参数重新训练并输出每个特征对应的权重 best_model grid.best_estimator_StratifiedKFold在交叉验证时保持每一折的违约比例和全量数据一致这保证了 AUC 评估的稳定性。从实际经验看C 在 0.1 到 1 之间通常是效果最稳的区间。如果 C 很大比如 100模型的复杂度变高权重绝对值会变得很极端某个特征的小幅波动会带来违约概率的剧烈变化这在业务上不可接受——客户年龄差一岁风险评分就差 20 分说不通。3.2.1 为什么一般不用 predict 函数直接判结果sklearn 的predict()方法默认以 0.5 为阈值判断类别这在违约预测场景几乎总是错的。想想看样本中违约率只有 5%意味着模型输出的违约概率均值可能就在 0.05 附近你怎么可能指望 0.5 作为合适的分界线正确做法是使用predict_proba拿到连续概率值然后根据业务成本和收益来选定判别阈值。这个阈值不是模型参数它是策略参数。如果审批通过一个坏客户损失是 5000 元拒绝一个好客户损失是 1000 元失去利息收入那么阈值就应该向下调宁可多一些误伤也要减少坏账。一般我会画出 precision-recall 曲线观察召回率和精确率的平衡点。from sklearn.metrics import precision_recall_curve precisions, recalls, thresholds precision_recall_curve(y_test, y_pred_proba) # 找到召回率约0.7时对应的阈值 target_recall 0.7 idx np.argmin(np.abs(recalls - target_recall)) threshold thresholds[idx] print(f召回率 {recalls[idx]:.3f} 对应的阈值: {threshold:.4f}) print(f该阈值下精确率: {precisions[idx]:.3f}) # 计算在该阈值下的预测结果 y_pred (y_pred_proba threshold).astype(int)precision_recall_curve函数返回三组数组依次遍历不同阈值下的精确率和召回率。上面代码先找到最接近目标召回率的索引然后取出对应的阈值。注意这里的阈值是连续的不是predict里的固定 0.5它应该写死在业务规则里上线的时候作为一个配置文件参数传给实时评分服务。3.3 类别不平衡的两种处理路径样本里坏客户占比只有 3%5% 时逻辑回归的截距项会被拉向负无穷导致所有样本的违约概率都集中在 0.05 以下。虽然排序性AUC可能还不错但概率值本身失去了业务含义。处理方式有两条路径。第一条是class_weightbalanced逻辑回归的损失函数会给少数类样本更高的权重让模型更“在意”违约样本。优点是直接嵌入 sklearn 接口不需要额外操作缺点是改变了样本的原始分布训练出来的概率不是真实的违约概率输出值是一个被扭曲的“加权概率”不经过校准不能直接当业务概率用。第二条是下采样downsampling把好客户样本抽到和坏客户数量接近的比例比如 2:1 或 5:1。这个方案训练出来的模型概率也是偏的但偏的方向可预期可以用公式校正回来。实际业务中我偏向用class_weightbalanced建模然后对输出概率做概率校准后面第 5 章会细说操作链路最短。4. 评估违约预测模型从混淆矩阵到 KS、AUC 与业务排序性4.1 不平衡场景下混淆矩阵里的准确率是陷阱模型训练完第一个要看的不是 accuracy而是混淆矩阵中“违约样本有多少被正确识别出来”。准确率在不平衡数据里没有信息量这个数学事实前文提过但真正决定模型能不能商用的是这四个数字的分布。from sklearn.metrics import confusion_matrix # 设定一个业务可接受的阈值 th 0.3 y_pred (y_pred_proba th).astype(int) cm confusion_matrix(y_test, y_pred) tn, fp, fn, tp cm.ravel() print(f真正例(正确预测违约): {tp}) print(f假负例(把违约预测成正常): {fn}) print(f假正例(把正常预测成违约): {fp}) print(f真负例(正确预测正常): {tn}) # 召回率 模型识别出来的违约客户 / 实际全部违约客户 recall tp / (tp fn) # 精确率 模型预测为违约的客户中确实违约的比例 precision tp / (tp fp) print(f召回率: {recall:.3f}, 精确率: {precision:.3f})confusion_matrix返回的矩阵行是真实标签列是预测标签。.ravel()展开后顺序依次是 tn, fp, fn, tp。召回率又叫真正率它衡量的是漏网之鱼的多少精确率衡量的是误伤比例。在信贷业务里两个指标的优先级需要业务方拍板但通常召回率更敏感因为漏掉一个坏客户造成的损失远远大于拒绝一个好客户的利息损失。另有一个指标容易被忽略覆盖度。如果测试集中违约样本本身只有 200 个即使召回率 0.8也只是识别出 160 个坏客户业务规模的绝对值受限。风控模型上线后通常还要配合人工审核队列来弥补召回不足的部分。4.2 AUC 与 KS不看单一数字看曲线形态AUC 是排序性指标它回答的问题是“随机抽一个违约客户和随机抽一个正常客户模型给违约客户打出更高概率的把握有多大”。AUC 0.8 以上算可用的模型但两个 AUC 完全相同的模型在业务上可能差别很大。比如一个模型在 0.6 以下概率区间区分度好另一个在 0.6 以上区分度好两者 AUC 会接近但审批阈值落在不同区间时效果天差地别。所以我要计算 KS 曲线它是风控评分模型最核心的验证手段。KS 统计量是每个概率分位点上累计坏客户比例与累计好客户比例之差的最大值。def calculate_ks(y_true, y_score): 计算KS值累计坏样本占比与累计好样本占比的最大差值 df_ks pd.DataFrame({y_true: y_true, y_score: y_score}) # 按预测概率升序排列概率越高越有可能是违约 df_ks df_ks.sort_values(y_score, ascendingTrue).reset_index(dropTrue) df_ks[cum_bad] (df_ks[y_true] 1).cumsum() / (df_ks[y_true] 1).sum() df_ks[cum_good] (df_ks[y_true] 0).cumsum() / (df_ks[y_true] 0).sum() df_ks[ks] np.abs(df_ks[cum_bad] - df_ks[cum_good]) return df_ks[ks].max(), df_ks ks_value, ks_df calculate_ks(y_test, y_pred_proba) print(fKS值: {ks_value:.4f}) print(fKS达到最大值时对应的预测概率: {ks_df.loc[ks_df[ks].idxmax(), y_score]:.4f})这里按预测概率升序排列后通过累计求和的方式计算每个切点下好坏客户的累计比例差。KS 值越高表示模型区分好坏客户的能力越强经验参考值0.3 以上模型有使用价值0.4 以上区分度很好。注意 KS 报告时要同时记录最大值出现的位置比如 KS 0.35 出现在概率 0.6 附近意味着高分段区分度好如果出现在 0.2 附近说明模型在低分段更有区分力业务上可以调低审批阈值来利用这部分能力。4.3 分数分箱校验排序性的最终验证方法AUC 和 KS 都是抽象的统计量业务方不一定关心但“把预测概率分成 10 档每档的实际违约率是否单调递增”这句话谁都能看懂。这是模型上线前必做的最后一项验证目的就是确认模型在高分段和低分段的排序性和业务常识一致。# 把预测概率分成10档统计每档的实际违约率 df_result pd.DataFrame({y_true: y_test, y_score: y_pred_proba}) df_result[prob_bin] pd.qcut(df_result[y_score], q10, duplicatesdrop) bin_stats df_result.groupby(prob_bin, observedTrue).agg( 样本量(y_true, count), 违约率(y_true, mean) ).reset_index() # 计算每一档的平均预测概率 bin_stats[平均预测概率] df_result.groupby(prob_bin, observedTrue)[y_score].mean().values print(bin_stats.round(4))pd.qcut按分位数把预测概率切分保证每档样本量接近便于比较违约率。理想状态下从最低档到最高档违约率应该持续上升不允许有倒挂现象。如果出现第 5 档违约率 0.03、第 6 档只剩 0.02 这种情况说明有几个特征在中间概率区间的排序能力有缺陷应该回退到特征工程阶段修整。下表是我最近一次模型验证时的输出示例分箱区间样本量平均预测概率实际违约率第 1 档3560.0210.008第 2 档3550.0450.017第 3 档3560.0680.031第 4 档3550.0940.045第 5 档3560.1280.062第 6 档3550.1790.089第 7 档3560.2460.124第 8 档3550.3450.185第 9 档3560.5120.296第 10 档3550.7830.548实际违约率从 0.8% 一直升到 54.8%单调性完整这一版模型就可以进入下一步。5. 实时评分落地概率校准、推理一致性与监控5.1 把模型概率映射成业务分数模型训练完成后要部署到实时评分环境中常见做法是把概率映射成 0-1000 的整数分数方便业务设定审批规则。映射公式逻辑回归和评分卡是标准配套score offset factor × ln(odds)其中odds p / (1 - p)p 是违约概率。设基准分 600 对应 odds 为 50:1每增加 20 分 odds 翻倍那么 offset 和 factor 可推导为import math # 基准分odds50时的分数PDOodds翻倍时增加的分数 base_score 600 base_odds 50 pdo 20 factor pdo / math.log(2) offset base_score - factor * math.log(base_odds) def prob_to_score(p): 违约概率转评分分数越高风险越低 odds p / (1 - p) return int(round(offset factor * math.log(odds))) # 验证几个关键点 print(f违约概率0.2 - 评分 {prob_to_score(0.2)}) print(f违约概率0.02 - 评分 {prob_to_score(0.02)})这里默认了违约概率越高、分数越低也就是分数和风险成反比。实际业务里有些团队会用反向分数即分数越高风险越高映射公式相应取负号。关键点是这个映射不会改变模型的排序性只是换了一个业务更友好的刻度尺。上线前应该在测试集上完整算一遍分数分布确认没有出现大量客户挤在同一分数段的“堆叠”现象分数分布应该大致连续。5.2 概率校准训练概率与真实违约率的偏差修正前面使用class_weightbalanced训练的模型输出的原始概率不是真实违约率需要校准。最简单可靠的方案是 Platt Scaling它用一个逻辑回归把模型原始输出映射到真实概率上。from sklearn.calibration import CalibratedClassifierCV # 用训练集拟合在测试集上校准 calibrated_model CalibratedClassifierCV( estimatorbest_model, methodplatt, # Platt Scaling适合逻辑回归这类模型 cv5 ) calibrated_model.fit(X_train_woe, y_train) calibrated_proba calibrated_model.predict_proba(X_test_woe)[:, 1] # 校准前后对比分箱统计真实违约率 df_cal pd.DataFrame({y_true: y_test, calibrated_proba: calibrated_proba}) df_cal[bin] pd.qcut(df_cal[calibrated_proba], q10, duplicatesdrop) comparison df_cal.groupby(bin, observedTrue).agg( 平均预测概率(calibrated_proba, mean), 实际违约率(y_true, mean) ) print(comparison.round(4))CalibratedClassifierCV的cv5表示用 5 折交叉验证来拟合校准器避免校准过程过拟合。校准后的预测概率可以当作真实的违约概率来用这就是“概率校准”的实际目的。做完这步后业务就可以拿预测概率×贷款金额来预估坏账损失这个数值对决定审批策略很有价值。校准做完后必须重新验证一次排序性如果校准后的分箱违约率出现部分倒挂说明原来的best_model在边界样本上表现不稳定宁可退回未校准版本也不能为了概率含义牺牲排序。5.3 训练推理一致性检查清单模型上线时最容易出的问题不是模型效果不好而是推理时的特征计算和训练时不一致。典型事故包括训练时对缺失值填充了中位数上线代码里填充了均值分箱边界训练时用的是pd.qcut自动计算的边界上线时重新用全量数据跑了一次qcut导致同一个客户的分数和训练时对不上。我建议把记录分箱边界的代码写成一个独立文件部署时连同模型一起加载import joblib # 保存分箱边界和模型到同一目录 joblib.dump(bin_edges_dict, feature_bin_edges.pkl) # 上线推理时统一用保存的边界做分箱 def apply_woe_to_newdata(bin_edges, features_df, feature_names): 推理时对每个特征按保存的边界分箱并映射WOE woe_values [] for feature in feature_names: edges bin_edges[feature] # 使用pd.cut传入训练时确定的边界而不是重新计算分位数 binned pd.cut(features_df[feature], binsedges, include_lowestTrue) mapped_woe binned.map(woe_map_dict[feature]) woe_values.append(mapped_woe.astype(float)) return pd.concat(woe_values, axis1)关键差异在pd.cut和pd.qcut的使用场景。qcut用于训练时自动计算边界是数据驱动的推理时要使用固定的边界列表必须用cut传入训练时保存的bins。这个文件要打上版本号和训练运行时的代码版本绑定。上线后前一周每天对比线上评分分布的均值、标准差和 PSI群体稳定性指标如果某一特征的 PSI 超过 0.1就说明线上数据分布偏移严重需要排查特征来源或重新训练。实时评分服务一般把模型序列化和规则参数放在配置中心模型效果监控通过定时任务离线计算当前批次评分和违约标签的相关性做到日级反馈。本文还有配套的精品资源点击获取