尧图精选

BP神经网络在个人信贷信用评估中的建模实践与避坑指南

🕒 发布时间:2026/10/1 4:14:13 📁 来源:尧图网络
简介一套基于BP神经网络的个人信贷信用评估MATLAB实现面向金融风控入门者及机器学习初学者可用于理解神经网络在信用评级场景中的完整建模流程。压缩包仅3个文件总计28KB包含主运行脚本、德国信用数据集german.data及其数值化版本结构紧凑、开箱即用也适合课程设计或个人项目实践。运行main.m即可复现整个评估流程测试20次的平均正确率为74.97%最低正确率73.4%迭代次数稳定为3次结果与作者博文分析相互印证。目前已有335人学习下载适合希望快速上手BP神经网络分类任务、对照代码理解数据预处理与模型评估细节的读者。借助这份代码可清晰看到特征输入、网络构建、迭代训练和准确率统计的完整链路为后续扩展更复杂的信用评分模型提供扎实起点。1. 为什么信贷信用评估会用BP神经网络来做信贷审核大概是金融领域里最“费人”的环节。一个审批员一天要过几百份申请看收入证明、查征信报告、核负债率判断这个人到底会不会按时还钱。规则引擎能解决一部分比如“负债率超过50%直接拒”但现实中的违约行为根本不是几条线性规则能框住的——有人收入很高但习惯性透支有人收入一般却还款极其稳定。这种“非线性”的信贷风险模式恰好是BP神经网络擅长捕捉的东西。基于BP神经网络的个人信贷信用评估本质上就是用一套带标签的历史信贷数据借过钱的人 他们最终是否违约训练一个多层前馈网络让模型学会从借款人的属性特征年龄、收入、负债、学历、历史逾期次数等映射到违约概率。项目打包成zip说明里面大概率是“数据集 Python代码 说明文档”三件套属于典型的“下载就能跑”的教学级风控模型。这篇文章就沿着“数据怎么准备 → 网络怎么构建 → 参数怎么调 → 坑在哪里 → 怎么验证”这条线完整走一遍。不管是金融专业的学生做课程设计还是刚转行风控的算法工程师搭第一个模型都可以照着做。使用 BP 神经网络做信用评估有一个先决条件你手里必须有一批已经标记好“是否违约”的历史样本。没有这个前提神经网络再强大也无从学起。所以下文从数据准备工作开始。2. 信贷评估的数据是怎么准备的字段选择、清洗与归一化2.1 字段怎么选从信贷申请信息里筛出能进模型的14个特征信贷信用评估的数据集通常来自银行或持牌金融机构的历史放贷记录每一条样本对应一笔贷款标签是“借款人最终是否违约”。原始数据里字段很多身份证号、姓名这类纯标识字段不能进模型居住地址、工作单位如果做不了有效编码也要丢掉。我一般保留下面这14个特征它们既覆盖了借款人的还款能力收入、负债也覆盖了还款意愿历史逾期、查询次数特征类型含义age数值借款人年龄income数值年收入单位万元debt_ratio数值负债率月还款/月收入loan_amount数值申请贷款金额loan_term数值贷款期限月credit_score数值征信评分如FICO风格的分数history_overdue数值历史逾期次数credit_accounts数值征信报告中的信贷账户数inquiries_last6m数值近6个月征信查询次数employment_years数值当前工作年限education类别学历高中/大专/本科/硕士house_loan类别是否有房贷car_loan类别是否有车贷marital类别婚姻状况选字段有个原则“宁可少而精不要多而杂”。BP神经网络不像树模型那样自带特征选择能力喂进去一堆噪音特征它会把噪音也一并记住直接表现为过拟合。比如借款人的“手机号运营商”移动/联通/电信这类特征对违约预测几乎没有区分度我一般直接丢弃。2.2 数据清洗与归一化缺失值填充和数值化的标准做法信贷数据是最脏的数据之一。字段缺失、异常值、类别变量未编码这三类问题基本每次都会遇到。以一个常见流程为例读取原始CSV之后清洗脚本的常见写法如下import pandas as pd import numpy as np df pd.read_csv(credit_data.csv) # 1. 删除纯标识列 df df.drop(columns[loan_id, name, id_card]) # 2. 缺失值处理数值列用中位数填充类别列用众数填充 num_cols df.select_dtypes(include[np.number]).columns cat_cols df.select_dtypes(include[object]).columns for col in num_cols: df[col] df[col].fillna(df[col].median()) for col in cat_cols: df[col] df[col].fillna(df[col].mode()[0]) # 3. 类别变量数值化用map映射成有序整数 education_map {高中: 0, 大专: 1, 本科: 2, 硕士及以上: 3} df[education] df[education].map(education_map) # 4. 二分类变量直接转0/1 df[house_loan] df[house_loan].map({是: 1, 否: 0}) df[car_loan] df[car_loan].map({是: 1, 否: 0}) # 5. 归一化所有数值特征缩放到[0,1] from sklearn.preprocessing import MinMaxScaler feature_cols [age, income, debt_ratio, loan_amount, loan_term, credit_score, history_overdue, credit_accounts, inquiries_last6m, employment_years, education, house_loan, car_loan, marital] scaler MinMaxScaler() df[feature_cols] scaler.fit_transform(df[feature_cols]) df.to_csv(credit_data_clean.csv, indexFalse)逻辑说明第2步中位数填充适合信贷这种存在长尾分布的字段比如income可能少数人极高用均值填充会被极端值拉偏中位数更稳健。第5步MinMaxScaler把特征统一到[0,1]这是BP神经网络训练的关键前提——原始量纲差异过大年龄是两位数、收入是六位数会导致梯度更新被大数值特征主导网络很难收敛。注意fit_transform只在训练集上执行测试集用训练好的scaler做transform。如果对全量数据做fit相当于把测试集的信息泄露给了训练过程后面的评估指标会虚高。这个问题在第五章的避坑清单里会出现。2.3 样本不均衡为什么“全都预测不违约”也能有90%的准确率信贷违约率通常只有2%5%这意味着100个样本里最多5个违约者。如果直接拿原始数据训练BP神经网络会学到一个极其偷懒的策略——把所有样本都预测为“不违约”整体准确率能达到95%以上但违约用户一个都抓不到。从业务角度看风控模型最核心的价值是“抓到违约的人”。所以对违约样本做处理是必须的。我常用的处理是两种过采样和加权。过采样最简单的方式是对违约样本做重复抽样或SMOTE生成合成样本加权则是在损失函数里给违约样本更大的惩罚系数。from imblearn.over_sampling import SMOTE X df[feature_cols].values y df[default].values # 2:1的比例做重采样避免过度放大违约样本 smote SMOTE(sampling_strategy0.5, random_state42) X_resampled, y_resampled smote.fit_resample(X, y) print(原始样本分布:, np.bincount(y)) print(重采样后分布:, np.bincount(y_resampled))逻辑说明sampling_strategy0.5表示把少数类样本数量提升到多数类的50%而不是强行1:1。在信贷场景下把违约样本凑到和正常样本一样多会让模型产生“天下坏人一半”的错觉误杀率显著上升。0.5这个比例是实践中比较稳的起点后续可以根据业务容忍度调整。3. 用Python从零搭建BP神经网络结构设计与核心代码3.1 网络结构怎么定输入层、隐藏层和输出层的参数依据BP神经网络处理信贷评估典型结构是三层的全连接网络输入层节点数 特征维度上一章选了14个特征所以输入层是14个节点。隐藏层节点数 一个经验公式“特征数 输出数”的中间档再加一点余量常见做法是取输入层和输出层节点数平均值与输入层之间的值。14个输入、1个输出的话隐藏层8~12个节点是合理区间。节点太少学不到非线性关系节点太多会把训练数据背下来。输出层节点数 1输出值映射到[0,1]代表违约概率或者用2个节点配合softmax代表“违约/不违约”两个类别的概率。二分类问题用单输出加sigmoid更直观参数也更少。激活函数的选择直接影响到梯度传播。隐藏层用tanh或ReLU输出层用sigmoid。ReLU收敛快但神经元容易“死亡”梯度恒为0tanh没有这个问题但计算量略大。信贷数据量一般只有几万到几十万条训练成本可忽略我倾向于隐藏层用tanh梯度更平稳。3.2 BP神经网络的Python实现用NumPy手写前向传播和反向传播很多读者在zip包里拿到的就是下面这类代码。用NumPy手写BP网络虽然比直接用Keras麻烦但方便你真正理解反向传播每一步在干什么后面调参也不会觉得是玄学操作。import numpy as np def sigmoid(x): return 1 / (1 np.exp(-x)) def sigmoid_derivative(x): s sigmoid(x) return s * (1 - s) def tanh(x): return np.tanh(x) def tanh_derivative(x): return 1 - np.tanh(x) ** 2 class BPNetwork: def __init__(self, n_input, n_hidden, n_output, learning_rate0.01): # 随机初始化权重缩放系数帮助控制梯度传播 self.w1 np.random.randn(n_input, n_hidden) * 0.5 self.b1 np.zeros((1, n_hidden)) self.w2 np.random.randn(n_hidden, n_output) * 0.5 self.b2 np.zeros((1, n_output)) self.lr learning_rate def forward(self, X): # 隐藏层用tanh输出层用sigmoid self.z1 np.dot(X, self.w1) self.b1 self.a1 tanh(self.z1) self.z2 np.dot(self.a1, self.w2) self.b2 self.a2 sigmoid(self.z2) return self.a2 def backward(self, X, y, output): m X.shape[0] # 交叉熵损失的梯度output - y是sigmoid交叉熵的简化公式 dz2 output - y.reshape(-1, 1) dw2 np.dot(self.a1.T, dz2) / m db2 np.sum(dz2, axis0, keepdimsTrue) / m # 从隐藏层到输出层的梯度回传 da1 np.dot(dz2, self.w2.T) dz1 da1 * tanh_derivative(self.z1) dw1 np.dot(X.T, dz1) / m db1 np.sum(dz1, axis0, keepdimsTrue) / m # 参数更新 self.w2 - self.lr * dw2 self.b2 - self.lr * db2 self.w1 - self.lr * dw1 self.b1 - self.lr * db1 def train(self, X, y, epochs500, batch_size32): n X.shape[0] for epoch in range(epochs): # 每个epoch打乱样本顺序 idx np.random.permutation(n) X_shuffled X[idx] y_shuffled y[idx] for i in range(0, n, batch_size): X_batch X_shuffled[i:ibatch_size] y_batch y_shuffled[i:ibatch_size] output self.forward(X_batch) self.backward(X_batch, y_batch, output)逻辑说明dz2 output - y是sigmoid输出配合交叉熵损失时的梯度简化结果它跳过了对sigmoid求导的中间步骤梯度形式更简洁也更快。因为输出层用的激活函数是sigmoid且损失函数是交叉熵这两个组合在一起相互抵消了sigmoid导数中output*(1-output)那一项。如果你把输出层的激活函数换成ReLU但保留这个梯度公式训练一定出问题。参数说明learning_rate0.01是相对保守的起点。信贷特征归一化之后数值范围在[0,1]权重初始化用np.random.randn(n_input, n_hidden) * 0.5保证乘积不会过大或过小避免梯度消失或梯度爆炸。batch_size32是常见默认值数据量在几万条时32是比较均衡的选择。3.3 训练集、验证集、测试集的切分逻辑有监督学习必须把数据切分成三份BP神经网络尤其需要“验证集”来监控训练过程中的过拟合而不是等全部训练完再拿测试集看效果。from sklearn.model_selection import train_test_split # 先分出测试集20%剩下80%再分为训练集(85%)和验证集(15%) X_train, X_test, y_train, y_test train_test_split( X_resampled, y_resampled, test_size0.2, random_state42, stratifyy_resampled ) X_train, X_val, y_train, y_val train_test_split( X_train, y_train, test_size0.15, random_state42, stratifyy_train ) print(X_train.shape, X_val.shape, X_test.shape) # 训练模型 model BPNetwork(n_inputX_train.shape[1], n_hidden10, n_output1, learning_rate0.01) for epoch in range(1000): model.train(X_train, y_train, epochs1) if epoch % 100 0: val_output model.forward(X_val) val_loss -np.mean(y_val.reshape(-1, 1) * np.log(val_output 1e-8) (1 - y_val.reshape(-1, 1)) * np.log(1 - val_output 1e-8)) print(fepoch {epoch}, val_loss: {val_loss:.4f})stratifyy_resampled的作用是让切分后的训练集和测试集保持和原数据相同的违约比例避免测试集里恰好多抽到几个违约样本导致评估波动太大。交叉熵损失加了1e-8防止log(0)出现NaN。4. 训练与调参学习率、隐藏层节点数和正则化的调整经验4.1 学习率怎么调0.01的默认值什么时候要改上一节的代码用了learning_rate0.01这个值在特征归一化后的信贷数据上通常能收敛但不是最优值。学习率过大损失函数会出现震荡典型现象是loss在某个值附近来回跳不下降学习率过小训练几百个epoch后loss还在缓慢下降浪费算力。一个务实的做法是设置一个自适应学习率当验证损失连续N个epoch不下降时把学习率缩小为原来的一半class AdaptiveNetwork(BPNetwork): def train_with_adaptive_lr(self, X_train, y_train, X_val, y_val, epochs1000, patience50): best_loss float(inf) bad_epochs 0 lr_history [] for epoch in range(epochs): self.train(X_train, y_train, epochs1) val_output self.forward(X_val) val_loss -np.mean(y_val.reshape(-1, 1) * np.log(val_output 1e-8) (1 - y_val.reshape(-1, 1)) * np.log(1 - val_output 1e-8)) lr_history.append(self.lr) if val_loss best_loss: best_loss val_loss bad_epochs 0 else: bad_epochs 1 if bad_epochs patience: # 验证损失不再下降学习率减半 self.lr * 0.5 bad_epochs 0 print(fepoch {epoch}: learning rate decayed to {self.lr:.6f}) if epoch % 200 0: print(fepoch {epoch}: val_loss {val_loss:.4f}, lr {self.lr:.6f})学习率衰减之所以有效是因为训练前期需要大步长快速到达损失函数低谷附近后期在低谷附近需要小步长微调避免一步跨过最优点。patience50表示连续50个epoch验证损失没有改善就减半这个值是经验值。如果数据量大、epoch数多可以适当增大到100。4.2 隐藏层节点数从欠拟合到过拟合的临界点在哪里信贷数据特征维度是14隐藏层节点数从5到20逐个尝试对比验证损失是最朴素也最有效的选参方法。过拟合的典型表现是训练损失持续下降验证损失先降后升。模型把训练样本的个性化特征当成了通用规律。下表是同一份数据、同一个学习率(0.01)、训练500轮的结果模式隐藏层节点数训练集准确率验证集准确率特征484%82%欠拟合两者都偏低891%90%拟合良好1697%89%过拟合训练验证差距大3299%85%严重过拟合直接把训练样本背下来了从表里的趋势能看出节点数从4增加到8训练和验证准确率同步提升说明模型容量不够还在“欠拟合”区。从8增加到16训练集继续涨但验证集开始掉此时的隐藏层节点数已经跨过临界点。动手调参时的经验判断如果训练和验证准确率的差距超过5个百分点大概率过拟合了要么减少节点数要么加正则化。4.3 正则化与早停让模型别把训练数据背下来除了控制节点数量两个常用手段防止BP网络过拟合L2正则化和早停。L2正则化的思路是在损失函数里加所有权重的平方和惩罚过大的权重网络不得不把决策边界做得更平滑。早停更简单——验证损失连续N个epoch不下降就停止训练保留验证损失最小的那组权重。class RegularizedNetwork(BPNetwork): def __init__(self, n_input, n_hidden, n_output, learning_rate0.01, l2_lambda0.001): super().__init__(n_input, n_hidden, n_output, learning_rate) self.l2_lambda l2_lambda def backward(self, X, y, output): m X.shape[0] dz2 output - y.reshape(-1, 1) dw2 np.dot(self.a1.T, dz2) / m self.l2_lambda * self.w2 db2 np.sum(dz2, axis0, keepdimsTrue) / m da1 np.dot(dz2, self.w2.T) dz1 da1 * tanh_derivative(self.z1) dw1 np.dot(X.T, dz1) / m self.l2_lambda * self.w1 db1 np.sum(dz1, axis0, keepdimsTrue) / m self.w2 - self.lr * dw2 self.b2 - self.lr * db2 self.w1 - self.lr * dw1 self.b1 - self.lr * db1l2_lambda是正则化强度的系数。0.001是起点调大则权重被压得更小决策边界更平滑但可能欠拟合调小则正则化效果减弱。权重衰减和早停可以同时用很多初学读者把这两个概念混在一起。简单区分L2是每轮更新都在压权重早停是直接掐断训练。信贷风控场景下数据量不大两者同时开的效果通常比单开一个更稳。5. 避坑指南信用评估模型里最常见的5个翻车现场5.1 翻车一归一化参数错误作用于全量数据导致数据泄露现象训练集准确率很高验证集和测试集表现也还行但上线后预测完全不准。原因对全量数据包括测试集一起做fit_transform归一化的min和max已经“看见”了测试集的信息。测试阶段的真实数据如果超出这个范围模型相当于在做超范围外推。解决先切分数据再对训练集fit_transform测试集只做transform。这一点在上文代码中已经体现但zip包里的示例代码最常见的坑就是直接对全量数据做归一化。检查方式很简单看代码里fit_transform是作用于整个df还是只作用于X_train。5.2 翻车二输出层梯度公式写死换了激活函数就废了现象网络输出层改成ReLU或者直接去掉激活函数后loss出现NaN权重变成极大值。原因dz2 output - y这个梯度公式只在“sigmoid输出 交叉熵损失”的组合下成立。因为交叉熵对sigmoid输出求导时sigmoid导数被抵消了。但如果输出层换成线性激活梯度应该写成2*(output-y)/mMSE损失而不是output-y。解决不要脱离场景复用梯度公式。改输出层激活函数时先把损失函数的梯度重新推导一遍。最简单可靠的方式是输出层始终用sigmoid、损失始终用交叉熵不要混搭。5.3 翻车三样本不均衡处理不当模型变成“全拒贷”机器现象训练完成后看准确率96%但打印混淆矩阵发现违约用户的召回率只有3%模型几乎把所有违约客户都判定为“不违约”。原因违约样本占比太低网络根本没见过足够的违约样本学不出违约模式。准确率高只是假象因为把所有人判成“不违约”就能获得95%以上的准确率。解决使用SMOTE或者class_weight。如果不用过采样手动给违约样本在损失函数里乘一个权重比如违约样本权重设为10正常样本权重为1这样梯度更新时违约样本的贡献被放大。但权重不能设得太大否则模型会过度激进把大量正常客户误杀。5.4 翻车四训练loss震荡不收敛在某个值附近来回横跳现象loss曲线像锯齿一样前100轮下降之后在一个区间内来回弹跳无法进一步降低。原因学习率过大梯度更新步长跨越了损失函数的狭窄谷底在谷底两侧反复横跳。这在使用固定学习率时非常常见尤其是网络结构加深或节点数增大后梯度尺度变大。解决先把学习率除以10观察loss变化如果下降变平稳说明原来的学习率确实过大。另一个更优雅的方案是使用动量法在梯度更新时叠加历史梯度的指数衰减平均相当于给更新方向增加“惯性”能明显抑制震荡。5.5 翻车五训练集和验证集划分不做分层抽样评估指标剧烈波动现象同一份代码只是改了随机种子验证集AUC在0.82和0.91之间反复跳动。原因没有使用stratify参数。违约样本本身只有5%左右切分时如果不做分层验证集里可能只有几个违约样本算出的指标随机性极大。解决train_test_split必须加stratifyy。更进一步直接用StratifiedKFold做K折交叉验证把数据切成5份轮流做验证集最终指标取5次的平均值。这样得到的模型效果评估才稳定不会因为随机种子不同笑果不同。6. 模型效果的验证与落地从实验指标到业务判断模型训练完毕不能只看准确率。信贷风控场景里误杀一个正常客户损失的是一笔可能盈利的贷款放走一个违约客户损失的是一笔本金。两类错误的代价不同最实用的评估工具是混淆矩阵和ROC曲线。from sklearn.metrics import confusion_matrix, roc_auc_score, roc_curve # 用测试集得到预测概率 y_proba model.forward(X_test).flatten() # 以0.5为阈值判定违约 y_pred (y_proba 0.5).astype(int) cm confusion_matrix(y_test, y_pred) print(混淆矩阵:) print(cm) # 输出格式 # [[TN, FP], # [FN, TP]] auc roc_auc_score(y_test, y_proba) print(fAUC: {auc:.4f}) # 尝试不同阈值观察召回率和精确率的权衡 for threshold in [0.3, 0.4, 0.5, 0.6, 0.7]: y_pred_t (y_proba threshold).astype(int) cm_t confusion_matrix(y_test, y_pred_t) recall cm_t[1][1] / (cm_t[1][0] cm_t[1][1]) precision cm_t[1][1] / (cm_t[1][0] cm_t[1][1] cm_t[0][1]) print(fthreshold{threshold:.1f}, recall{recall:.3f}, precision{precision:.3f})信贷评估里我见过最多的情况是AUC有0.85但业务方还是不满意因为阈值0.5下召回率只有40%这意味着60%的违约客户没被拦下来。此时不比急着调模型结构试着重叠阈值到0.3或0.35召回率能显著提升。代价是误杀率上升但风控业务上“宁可错杀也不放跑”的倾向通常是接受这个代价的。BP神经网络做信用评估最大的价值不是拿到一个多恐怖的准确率数字而是给你一条从原始信贷数据到可部署风控规则的完整路径。模型本身只是个映射函数真正难的是不断迭代“特征-阈值-误杀容忍度”这三者的组合。我个人的习惯是每训练一版模型就把阈值-召回率-精确率表格存档标明数据版本和特征范围。三个月后重新训练时直接对比这张表哪个版本指标退化了、是特征漂移还是样本分布变化一目了然。用BP网络做风控耐心比模型结构更重要。希望这篇笔记能帮你少走几步弯路把zip里的代码跑通再跑出自己的判断。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →