简介:这是一份基于BP神经网络的个人信贷信用评估MATLAB工程包,主要面向金融风控入门者、机器学习课程设计与毕业设计学生。资源以德国信贷数据集为对象,包含可直接运行的MATLAB主脚本、原始german.data文件及数值化处理后的german.data-numeric文件,省去数据清洗与格式转换环节,可从数据导入、网络构建、训练测试到正确率统计完整走通个人信贷违约分类任务。压缩包内共3个文件,以MATLAB脚本和两类数据文件为主,整体仅28KB,结构精简、便于逐文件对照学习。代码在20次重复测试中平均正确率达74.97%,最低为73.4%,且迭代次数均为3次,适合用于分析BP神经网络的收敛稳定性、参数设置与评估指标选择。目前已有335人学习下载,对需要完成信用评估实验、理解神经网络实践流程或进行扩展改进的MATLAB学习者有直接参考价值。
1. BP神经网络做个人信贷信用评估:74.97%的准确率怎么来的
银行审批一笔个人贷款之前,最重复的工作就是给申请人的信用“打分”。这个资源把问题放到了德国个人信贷数据集上:压缩包里是german.data和german.data-numeric两份数据,配一套基于BP神经网络的MATLAB分类流程。按照项目摘要的信息,运行main.m测试20次,平均正确率74.97%,最低正确率73.4%,并且每次模型在第3轮迭代就收敛了。这个包适合正在做机器学习作业、想用MATLAB快速落地一个BP基线的人,也适合初入风控建模、需要一个可复现对照实验的研究生。它不追求SOTA,但数据、脚本、结果三者闭环,能把“BP做个人信贷评估”这条链路一次走通。
2. 选型与网络结构:为什么BP适合24维信贷数据
2.1 信贷评分本质是二分类,BP的隐藏层正好补上非线性交互
个人信贷信用评估落到模型层面就是一个二分类任务:把每条样本映射到“好客户”或“坏客户”。German Credit数据集常见版本有1000条样本,特征被整理为24个数值维度。这个规模说不上大,特征维度也不算高,最适合的起点就是一个单隐藏层BP网络,而不是一上来上LSTM或者Transformer。深度模型擅长序列建模和长程依赖,信贷评估的24个属性是静态的、与顺序无关的,硬套序列模型只会让参数量爆炸,1000条样本根本喂不饱。这也是为什么这个资源刻意选BP而不是更“时髦”的模型。
BP真正在线性模型之上的地方,是它能自动构造非线性特征组合。信用数据里单看任何一列都很难区分好坏客户:贷款金额大不一定坏,储蓄账户充足又不代表一定好,但“金额大+储蓄账户充足+就业稳定”这三个条件组合起来,信号就明显强得多。逻辑回归没有隐藏层,只能学一个线性加权和;朴素贝叶斯又假设特征条件独立,但信贷数据里贷款目的和贷款金额明显联动,独立性前提并不成立。BP在输入层和输出层之间加一层隐藏层,把原始特征先做加权求和、再过非线性激活函数,等价于在特征空间里切出一个可以弯曲的决策边界。对24维数据、1000条样本的问题,这种表达能力刚好够用,计算量也完全可以忽略。
要不要换成随机森林或者XGBoost?那是另一个维度的比较。做课程设计和模型基线时,先把BP跑通拿到一个参考值,再用树模型做对比,才能说明提升是来自模型还是来自调参。同类资源里还有用逻辑回归做信贷评分的版本,但逻辑回归看不到非线性交互,遇到这个数据集时上限会明显低于BP。所以压缩包选择BP,既照顾了教学意义,也照顾了实验可对比性。
| 方案 | 参数量级 | 对此数据集的适配点 | 主要风险 |
|---|---|---|---|
| BP单隐藏层 | 约几百 | 非线性拟合、样本量匹配 | 局部极小、过拟合 |
| 逻辑回归 | 25 | 解释性好 | 学不到非线性 |
| LSTM/Transformer | 很大 | 适合序列数据 | 样本不足、无法收敛 |
| 随机森林 | 取决于树数 | 稳健 | 解释成本变高 |
2.2 网络参数初值:用feedforwardnet搭一个24-10-1结构
拿到24维特征后,第一件事是定网络结构:输入层节点数等于特征数24;输出层只用1个节点就够了,二分类问题用单个输出加阈值切分,没必要做两个输出节点再加softmax;隐藏层节点数是个超参数,我习惯先把初值定成10,不是因为它最优,而是处于中间位置好比较,后续再用6、8、12、16做小范围搜索。
% 建立单隐藏层BP网络,隐藏层10个节点 net = feedforwardnet(10, 'trainlm'); % 隐藏层用tansig,输出层用logsig,得到0~1之间的输出 net.layers{1}.transferFcn = 'tansig'; net.layers{2}.transferFcn = 'logsig'; % 数据划分:按比例随机分成训练、验证、测试 net.divideFcn = 'dividerand'; net.divideParam.trainRatio = 0.7; net.divideParam.valRatio = 0.15; net.divideParam.testRatio = 0.15; net.trainParam.epochs = 1000; % 最大迭代轮次 net.trainParam.goal = 0.01; % 均方误差目标这里feedforwardnet(10, 'trainlm')的第一个参数是隐藏层神经元个数,第二个是训练算法。trainlm在MATLAB里对应Levenberg-Marquardt算法,适合千条级别的小样本数据,收敛速度非常快,后面日志里看到迭代3次就停住,主要就是它的功劳。dividerand表示按比例随机切割训练、验证、测试集,其中测试集完全不参与权重更新,验证集用来做早停判断,只有训练集真正更新权重。epochs是训练轮次上限,不是实际迭代次数,实际跑了多少轮要看训练返回的参数。goal是均方误差阈值,误差低于这个值也会提前结束训练。
为什么隐藏层不用logsig?经验上隐藏层激活函数用tansig比logsig更顺,原因是logsig输出范围0到1非零中心,会让后一层接收到的信号全部为正,权重更新方向受到限制;tansig输出范围-1到1,中心为0,梯度更新更稳定。输出层反而刻意用logsig,这样输出天然落在0到1之间,可以直接当成“好客户概率”看。若将来改用两个输出节点,就需要补一层softmax,在MATLAB里对应softmaxLayer,但目前在二分类下没有这个必要。
训练函数也可以换。trainlm默认占用内存比基本梯度下降多一些,但1000条数据根本构不成压力;如果换成traingd,那就是最原始的梯度下降,需要手动调学习率,迭代次数会大幅上涨;trainscg是适用于中规模问题的另一种选择,优势是省内存。这个资源用trainlm,是从收敛速度考虑的最优选择。千万不要一上来就调net.trainParam.lr,因为trainlm本身有自适应步长机制,手动改学习率反而会把默认节奏打乱,我早期犯过这个错,改了学习率后平均准确率不升反降。
3. german.data-numeric的读入与预处理:24列特征怎么切、怎么归一化
3.1 german.data-numeric和german.data的区别
压缩包里的german.data是最原始的文本格式,里面混着字符串分类字段,比如贷款目的、婚姻状态、就业情况。german.data-numeric是把它转成纯数值矩阵的版本,脚本真正读取的是numeric版。数值化过程会做两类操作:有序分类字段映射成递进整数,无序分类字段拆成多列0/1指示变量。因此,文件虽然标称24个属性,但严格说不是24个完全独立的原始维度,其中有相当一部分列来自同一个原始字段的拆分。提醒注意:加载后不要强求每一列都“阅读理解”,直接当作BP输入特征即可。
% 读取数值版数据集 rawData = load('german.data-numeric'); X = rawData(:, 1:24); % 前24列是特征 y = rawData(:, 25); % 最后一列是标签这里用load而不是textscan,是因为german.data-numeric已经是空格分隔的同构数值矩阵,load一次就能读成double矩阵。读完后先检查两件事:size(X)应为1000×24,unique(y)应看到标签取值,常见编码是1代表好客户、2代表坏客户。切列数必须确认,个别渠道下载的文件可能列数有出入,直接加载后打印尺寸比盲目信任文件名靠谱。
标签分布上,1000条样本里好客户约700条、坏客户约300条,类别不平衡不算严重,直接用准确率做评估指标有一定参考性。如果以后换成其他数据集且正负比到了9比1,那就要考虑在训练集里做重采样,或者把判好客户的阈值调高。这里先沿用原始数据分布,不做额外过采样。
3.2 训练/测试划分与mapminmax:先切分再归一化
预处理顺序是这个压缩包里最容易踩坑的地方。常见错误做法是把1000条样本整体做归一化,然后再随机切训练集和测试集,这样测试集的分布信息通过归一化参数进入了训练流程,测试结果会虚高。教案里有时图省事这么写,但严谨复现时结果会有偏差。正确顺序是先切分,再只基于训练集计算归一化参数,用同一套参数去变换测试集。
rng(1); % 固定随机种子,后续结果可复现 idx = randperm(size(X, 1)); % 随机打乱样本顺序 trainIdx = idx(1:700); testIdx = idx(701:1000); % mapminmax默认按行处理,先转置成【特征×样本】再归一化 [XtrainN, ps] = mapminmax(X(trainIdx, :)', 0, 1); XtrainN = XtrainN'; % 测试集复用训练集的ps参数,绝不能再重新mapminmax XtestN = mapminmax('apply', X(testIdx, :)', ps)';randperm(1000)生成不重复的随机下标,前700个作训练、后300个作测试,这是一个比较常见的7比3分割。mapminmax的第二个参数0、1指目标区间,把每维特征线性缩放后落在[0,1]。返回的ps结构保存了每维的最小值和缩放系数,测试集变换时用mapminmax('apply', ..., ps),参数改为-1到1也可以,tansig激活函数对[-1,1]区间的输入响应更好,但技术大方向一致。
为什么0/1指示列也要进mapminmax?因为拆分分类字段生成的列本来就是0或1,对它做线性缩放没有损伤;真正需要处理的是连续列,像贷款金额、年龄、贷款期限,数值量级不一,不缩放会使得数值大的维度在梯度计算里占据主导,网络训练被少数大数值列带偏。统一对所有维度做mapminmax,是省心且稳妥的选择。不要只挑“看起来大”的列处理,特征缩放要面向全部输入维度。
标签映射单独做一次,代码也一起放出来:
% 原始标签1=好客户,2=坏客户,映射成逻辑0/1 y01 = (y == 1); yTrain01 = y01(trainIdx); yTest01 = y01(testIdx);布尔表达式y==1把“好客户”置为1、坏客户置为0,这步不做的话,输出层logsig范围是0到1,但标签值却是2,网络会把它当回归任务去逼近2,训练根本收敛不到正确区间,混淆矩阵也会反着看。映射完成后,unique(yTrain01)应该只看到0和1两个取值。
4. 避坑与常见问题:运行main.m必查的五个细节
4.1 迭代都停在3次,是没训练完还是已经收敛?
现象:训练日志里epoch动不动就停在3,和平时见到的几百轮训练完全不一样,第一反应是脚本没跑完就中断了。实际上项目摘要里写明20次测试迭代次数均为3次,准确率却稳定在73%到75%附近。
原因:trainlm并不是标准的沿梯度方向固定步长走,它会结合近似Hessian矩阵做二阶方向修正,很多小样本问题在前几步损失就大幅下降,紧接着触发验证集早停或误差目标条件,所以会出现epoch=3就停止的情况。这是正常现象,不是脚本坏了。
解决:不要只看epoch,要看训练记录里的bestEpoch:
[net, tr] = train(net, XTrainN', yTrain01'); fprintf('实际迭代: %d\n', tr.epoch(end)); fprintf('验证最优轮次: %d\n', tr.bestEpoch);tr.epoch(end)是实际迭代轮次,tr.bestEpoch是验证集误差最小的轮次。这两者通常一致或接近。如果想让训练多跑几步,可以把net.trainParam.min_grad调小,或把max_fail从默认6调成更大值,但最终测试集准确率不一定跟着涨,反而可能因为后期过拟合而下降。
4.2 每次运行结果不一样:随机种子设在哪一步?
现象:同一份main.m跑两遍,结果一次是73.5%,一次是75.2%,怀疑数据或代码有随机性没被控制。这是BP实验里最常见的一种“玄学”波动,其实来源很清楚。
原因:MATLAB的randperm数据切分和网络权重初始化都依赖全局随机数生成器。每次MATLAB启动时随机状态不同,切分出的训练集不同、网络初始权重也不同,最终结果自然不同。
解决:在脚本最前面固定随机种子,只需一行:
rng(20250101); % 固定随机种子,使本次运行可复现固定之后,同一台机器跑出来的结果应该完全一致。如果想模拟“20次不同实验”并统计区间,就在循环内部每次设置不同种子:
for i = 1:20 rng(i); % 每次使用不同的可复现种子 % 后续训练代码 end再有就是注意MATLAB的net在train调用时会继承当前随机状态完成初始化,不需要额外执行init(net),强行调用反而可能让种子控制失效。
4.3 标签1和2没映射到0/1,混淆矩阵怎么看都是错的
现象:脚本跑完,准确率数字还算正常,但画混淆矩阵时发现好客户几乎全被判断成了坏客户,矩阵对角线反了。
原因:上一章强调过,原始标签1=好客户、2=坏客户。如果哪一步把y==2当成了正类,或在归一化前直接把标签矩阵整体做了mapminmax,都会让语义反转。更常见的是输出层logsig结果在0到1之间,而原始标签是2,分类阈值0.5把所有输出都判成“好客户=0”,指标自然全乱。
解决:统一走一遍标签映射,并在训练前后检查类别总数:
y01 = (y == 1); yTrain01 = y01(trainIdx); yTest01 = y01(testIdx); pred01 = (net(XTestN')' > 0.5); C = confusionmat(yTest01, pred01); disp(C);confusionmat返回2×2矩阵,第一行是真实好客户的预测分布,第二行是真实坏客户的预测分布。如果对角线数值明显大,说明方向对了;如果矩阵看起来像“反着”,把标签映射符号调换再跑一次。
4.4 隐藏层节点加多准确率反而下降:小样本过拟合
现象:隐藏层节点从10改成30,训练误差降得更低,但测试集准确率从74%掉到70%以下。新手容易在这里反复加节点,最终得到更差的模型。
原因:训练集只有700条样本,单隐藏层节点太多时,网络参数量上升,相当于加强了对训练集噪声的拟合能力,测试集上泛化变差。MATLAB里训练过程不会主动阻止过拟合,它只是把训练误差压下去。
解决:先做小范围节点扫描,而不是单点试错:
for h = [6, 8, 10, 12, 16] net = feedforwardnet(h, 'trainlm'); net.layers{1}.transferFcn = 'tansig'; net.layers{2}.transferFcn = 'logsig'; net.trainParam.showWindow = false; net = train(net, XtrainN', yTrain01'); pred = net(XtestN')'; acc = mean((pred > 0.5) == yTest01); fprintf('隐藏层=%2d, 准确率=%.2f%%\n', h, acc * 100); end这段代码每次重新建网络、重新训练、重新测试,不会累积上一次的网络状态。如果发现12和16节点准确率都低于8和10,说明结构复杂度已经越过拐点,回到10节点即可。若还想继续优化,再考虑在损失函数里加正则项,但不要先动正则项,先把结构复杂度控制在合理范围。
4.5 load报错找不到数据文件:中文目录和空格都是坑
现象:把压缩包解压到带空格或中文的路径下,运行main.m时load('german.data-numeric')报错,提示文件找不到。
原因:MATLAB对路径里的空格和中文字符处理并不总稳定,尤其是旧版本配合load、addpath这类函数时容易解析出错,工作目录没有正确cd到解压目录也会触发同样的现象。
解决:执行前先确认文件位置,再用安全路径操作:
if exist('german.data-numeric', 'file') ~= 2 error('数据文件不在当前目录,请先cd到解压目录'); end然后进入german.data-numeric所在目录,把工作区切过去,例如cd('E:/bp_credit')。路径建议统一用英文和数字组成,不要带空格,也不要放在桌面深层目录里。这个方法在连续跑了很久的项目上会有用,也有人说“玄学路径”,其实只是解析规则问题。
5. 验证技巧:用20次重复实验把74.97%复现出来
项目中写测试20次平均正确率74.97%,最低73.4%,这是一个很值得借鉴的验证方式。单次BP实验的随机波动可能掩盖真实模型水平,只看一次准确率就去调参,基本等于自己骗自己。理性的做法是把整个训练流程完整跑20次,每次重新打乱数据、重新初始化网络,最后看平均、最低和波动范围。
accs = zeros(1, 20); for i = 1:20 rng(i); % 每次不同且可复现 idx = randperm(size(X, 1)); trainIdx = idx(1:700); testIdx = idx(701:1000); [XtrainN, ps] = mapminmax(X(trainIdx, :)', 0, 1); XtrainN = XtrainN'; XtestN = mapminmax('apply', X(testIdx, :)', ps)'; yTrain01 = (y(trainIdx) == 1); yTest01 = (y(testIdx) == 1); net = feedforwardnet(10, 'trainlm'); net.layers{1}.transferFcn = 'tansig'; net.layers{2}.transferFcn = 'logsig'; net.trainParam.showWindow = false; % 循环里关掉训练图 net = train(net, XtrainN', yTrain01'); pred = net(XtestN')'; predLabel = double(pred > 0.5); accs(i) = mean(predLabel == yTest01); end fprintf('平均准确率: %.2f%%\n', mean(accs) * 100); fprintf('最低准确率: %.2f%%\n', min(accs) * 100);循环内部每轮都重新做数据划分、归一化和网络初始化,20次结果之间相互独立。最终打印的平均值应该接近74.97%,最低值接近73.4%。如果差得远,优先检查两处:一是mapminmax参数是否复用了ps,二是标签是否映射成0/1。
对应稳定复现的参数汇总如下:
| 配置项 | 设置 | 说明 |
|---|---|---|
| 隐藏层节点数 | 10 | 可扫描6/8/12/16 |
| 训练算法 | trainlm | 小样本收敛快 |
| 隐藏层激活函数 | tansig | 非零中心问题更少 |
| 输出层激活函数 | logsig | 输出0~1概率 |
| 归一化范围 | [0,1] | 处理不同量级的连续特征 |
| 训练/测试比例 | 700:300 | 与循环代码一致 |
| 随机种子 | rng(1)等 | 保证可复现 |
更进一步,可以统计混淆矩阵而不是只看准确率。把20次实验的混淆矩阵累积起来,取平均,能看到好客户和坏客户各自的召回率。信贷场景里把坏客户当成好客户放款的代价,远大于把好客户误判为坏客户,所以实际工程上经常会在输出概率上调整阈值:把0.5改成0.4或0.6,观察准确率和召回率如何移动。这也是把BP结果从“课程作业”推向“可用模型”的关键一步。
我最初跑这类BP脚本时也吃过单次结果定调子的亏。第一次跑了75%,高兴了很久,换一次随机种子变成73.8%,又以为是代码出了问题。后来才意识到神经网络本来就是随机模型,不固定种子、不跑循环均值,拿单次结果评估就是自己骗自己。从那以后我每次跑分类实验,都强制先把20次循环基线跑出来,确认稳定区间再动手调参。希望帮到你。
本文还有配套的精品资源,点击获取