简介:基于BP神经网络的个人信贷信用评估项目,主要面向金融风控领域初学者及机器学习入门者,提供一套可直接运行的Matlab分类方案,涵盖数据读取、网络训练与正确率评估环节。压缩包共含3个文件,包括核心的.m脚本、data-numeric及data格式的数据文件,整体仅28KB,结构精简,便于快速理解代码逻辑并作二次修改。脚本基于德国信贷数据样本完成信用评估建模,作者连续运行20次所得平均正确率为74.97%,最低正确率73.4%,且迭代次数稳定在3次,体现出较好的收敛稳定性,可作为后续改进的基线参照。利用该资源可快速搭建完整的BP信用评分流程,便于进一步探索网络结构、学习率等参数对分类结果的影响。资源已有334人浏览学习,适合希望掌握BP神经网络实际应用、复现信用评估实验并在此基础上调优的读者。
1. 为什么个人信贷评分会用BP神经网络
信贷审批这个场景很有意思,很多人第一反应是逻辑回归,因为它可解释性强。但真实征信数据里,收入、负债比、历史逾期次数这些特征之间,常常存在非线性交叉作用,比如“低收入高负债”和“高收入中负债”的风险差异并不线性。我拿到这个项目时,zip里核心文件是三个:german.data、german.data-numeric、credit_class.m,主脚本main.m把整套流程串起来。用MATLAB里最经典的BP神经网络对德国信用数据集做二分类,跑20次平均正确率74.97%,最低也在73.4%,迭代只需要3次就收敛。这个结果在学术上不算惊艳,但在工程复现角度非常稳定,尤其适合想做机器学习课设、金融风控入门或者需要快速跑通一个分类基线的人。
2. BP神经网络与信用评估:网络结构和参数选型的依据
2.1 为什么不用决策边界而是用BP
个人信贷数据里,特征类型很杂:有连续值(如贷款金额、年龄、信用历史时长),也有离散编码(如目的、储蓄账户状态)。逻辑回归或线性SVM假设特征与输出之间是线性加权关系,但真实风险往往出现在特征交互项上。比如“贷款目的是新购汽车”单独看风险中等,但如果结合“应付账户余额为负”和“近3个月查询次数多”,坏账概率会显著上升。BP神经网络的优势在于,隐含层神经元可以自动构造这些高阶交互特征,不需要手工做特征工程。
对于1000条样本的German Credit数据集,BP网络规模不需要很大。输入层对应20个特征,输出层1个节点,隐含层神经元数量过多会过拟合,过少欠拟合。常见做法是用经验公式预估一个范围,比如取输入层和输出层节点数之和的平方根附近,或者用(输入+输出)/2做起点,然后在10到25之间扫描。这个项目跑出来的稳定性和75%左右的正确率,说明网络容量和数据量是匹配的。
2.2 网络拓扑:从输入到输出的每一层如何确定
German Credit原始数据集中,每条样本有20个属性,所以输入层节点数定为20。输出层处理的是二分类问题,标签是“好客户”(1)和“坏客户”(2),用单节点输出更简洁,训练时把标签映射成0和1,输出值经过竞争函数或阈值判断最终类别。
隐含层节点数没有严格公式,以MATLAB的newff为例,常见参数表如下:
| 参数 | 推荐取值 | 说明 |
|---|---|---|
| 隐含层节点数 | 10 ~ 25 | 节点太多会记住噪声,太少学不到交互特征 |
| 输入层激活函数 | tansig | 双曲正切,输出范围[-1,1],收敛比logsig快 |
| 输出层激活函数 | purelin | 线性输出,适合二分类回归输出 |
| 训练函数 | trainlm | Levenberg-Marquardt,小数据集收敛极快 |
| 学习率 | 0.01 ~ 0.1 | 过大会振荡,过小会卡在局部最优 |
| 目标误差 | 1e-3 | 迭代3次就能达到,说明数据可分性好 |
在MATLAB中创建网络的典型代码如下:
% 创建BP网络:20个输入特征,隐含层15个节点,输出层1个节点 net = newff(minmax(P), [15 1], {'tansig', 'purelin'}, 'trainlm'); % 训练参数设置 net.trainParam.epochs = 1000; % 最大迭代次数 net.trainParam.goal = 1e-3; % 误差目标 net.trainParam.lr = 0.01; % 学习率 net.trainParam.showWindow = false; % 后台运行,不弹窗口这里minmax(P)用于获取输入数据的范围,newff会基于这个范围初始化权重和偏置。隐含层用tansig,输出层用purelin,这是MATLAB里做二分类回归的经典组合。trainlm是默认推荐,它结合了梯度下降和高斯-牛顿法的优点,对小规模数据集尤其有效。
2.3 训练参数的坑:迭代次数和正确率的关系
迭代3次收敛这个现象,第一次看到可能觉得网络没训练充分,但这实际是trainlm的特性。德国信用数据集只有1000条样本,特征维度20,LM算法在矩阵求逆时能一步走到误差极小值附近。需要注意,迭代次数少并不代表模型简单,相反,如果设置goal太小比如1e-6,反而可能陷入过拟合,把训练集噪声也学进去,测试集正确率不升反降。
我一般会保存训练过程中的误差曲线和验证集正确率,如果误差在第1次迭代就掉到1e-3以下,说明模型有足够容量去拟合数据,这时候更该关注的是测试集的稳定性,而不是继续压榨训练误差。这也是为什么该项目会测试20次再取平均,单次运行的正确率会受随机权重初始化影响,20次平均更能反映模型的真实泛化能力。
3. German Credit数据集:从文本到数值矩阵的预处理
3.1 german.data和german.data-numeric到底有什么区别
german.data是原始的文本格式,每一行代表一个客户,包含20个属性。其中既有纯数值属性,比如持续时间(月)、贷款金额、年龄,也有分类属性,比如个人状态和性别、目的、房产情况。分类属性的值用A11、A12这样的代码表示,不能直接喂给神经网络。
german.data-numeric则是经过数值化转换后的版本,每列已经是实数,方便直接加载。这个项目里credit_class.m的主要职责,就是完成从文本代码到数值索引的映射,并生成可直接用于训练和测试的数值矩阵。
3.2 文本格式的编码映射:credit_class.m在做什么
在原始german.data中,属性编码规则是固定的。比如“Status of existing checking account”有四种取值:A11代表小于0 DM,A12代表0到200 DM,A13代表大于200 DM,A14代表没有账户。这些取值没有大小含义,不能直接当连续值用。credit_class.m里常见的做法是用switch或containers.Map把每个Axx代码映射到一个整数序号,同时保证同一个属性的不同取值映射到连续区间,避免类别间产生错误距离。
简化后的逻辑大致是这样的:
% 假设读取german.data后得到cell数组raw_data % 对第1列进行编码映射 mapping = containers.Map({'A11','A12','A13','A14'}, {1, 2, 3, 4}); encoded_col = zeros(size(raw_data, 1), 1); for i = 1:length(encoded_col) if mapping.isKey(raw_data{i, 1}) encoded_col(i) = mapping(raw_data{i, 1}); else encoded_col(i) = str2double(raw_data{i, 1}); % 数值属性直接转换 end end这里containers.Map负责把类目标签映射成整数,比if-elseif更易维护。如果某个属性的分类取值很多,比如purpose有11种,可以按频率编码或直接用序号编码。german.data-numeric文件本身就是按这种逻辑生成的,所以用load加载后可以直接得到特征矩阵,省去手写解析的时间。
3.3 归一化和数据集划分:不能跳过的两步
BP神经网络的权重初始化通常在0附近,输入特征的尺度如果不一致,量级大的特征会主导梯度更新,导致训练不稳定。常见做法是用mapminmax把特征缩放到[-1,1]或[0,1]区间,缩放参数从训练集上计算,然后应用到测试集,避免数据泄露。
% 加载german.data-numeric data = load('german.data-numeric'); % 分离特征和标签 X = data(:, 1:20); % 前20列是特征 Y = data(:, 21); % 第21列是标签,1表示好客户,2表示坏客户 % 把特征归一化到[-1,1] [X_norm, ps] = mapminmax(X', -1, 1); X_norm = X_norm'; % 标签转换:将2变成0,使网络输出与标签一致 Y_binary = (Y == 1); % 好客户为1,坏客户为0 % 随机划分训练集和测试集,80%训练,20%测试 rng(42); idx = randperm(size(X_norm, 1)); train_idx = idx(1:round(0.8 * length(idx))); test_idx = idx(round(0.8 * length(idx)) + 1:end); X_train = X_norm(train_idx, :); Y_train = Y_binary(train_idx, :); X_test = X_norm(test_idx, :); Y_test = Y_binary(test_idx, :);需要注意,mapminmax默认按行处理,所以输入必须转置,得到的结果再转置回来。rng(42)固定随机种子,保证每次划分一致,这是复现结果的关键一步。如果不固定种子,20次测试的平均正确率会有一两个百分点的波动,但整体趋势不变。
4. 核心实现:main.m和credit_class.m如何完成一次完整训练
4.1 main.m主流程:加载数据、建网络、训练、20次重复测试
main.m是整个项目的主控脚本,它会调用credit_class.m完成数据解析,然后执行多轮训练和测试。多轮测试的价值在于,BP网络每次初始化的随机权重不同,单轮正确率可能偏高或偏低,循环20次取平均,才能真实反映模型稳定性。
可以用下面的伪代码描述主流程:
clear; clc; close all; % ---- 第1步:通过credit_class.m加载并处理数据 ---- [X_norm, Y_binary] = credit_class(); % 返回归一化后的特征和二值标签 % ---- 第2步:准备记录正确率 ---- all_acc = zeros(20, 1); % ---- 第3步:重复训练与测试20次 ---- for i = 1:20 % 随机划分训练集与测试集 rng(i); % 每次使用不同但可复现的随机种子 idx = randperm(size(X_norm, 1)); train_idx = idx(1:800); test_idx = idx(801:end); X_train = X_norm(train_idx, :); Y_train = Y_binary(train_idx, :); X_test = X_norm(test_idx, :); Y_test = Y_binary(test_idx, :); % 创建并配置BP网络 net = newff(minmax(X_train'), [15 1], {'tansig', 'purelin'}, 'trainlm'); net.trainParam.epochs = 1000; net.trainParam.goal = 1e-3; net.trainParam.showWindow = false; % 训练网络 [net, tr] = train(net, X_train', Y_train'); % 测试 Y_pred = sim(net, X_test'); Y_pred_binary = Y_pred > 0.5; % 概率大于0.5判定为好客户 % 计算正确率 acc = 1 - mean(abs(Y_pred_binary - Y_test')); all_acc(i) = acc * 100; end % ---- 第4步:输出平均正确率和最低正确率 ---- fprintf('平均正确率: %.2f%%\n', mean(all_acc)); fprintf('最低正确率: %.2f%%\n', min(all_acc));这里credit_class()返回的Y_binary是列向量,train要求输入输出为矩阵格式,行对应样本,列对应变量,所以需要转置。输出层使用purelin线性激活函数,得到的是一个连续预测值,判断时以0.5为阈值转成0/1。平均正确率74.97%就是这么统计出来的。
4.2 credit_class.m是如何工作的
credit_class.m不是网络训练函数,而是数据准备函数。它负责读取german.data文本文件,完成类别编码和数值化,最后输出特征矩阵和标签向量。如果只提供了german.data-numeric,它也可以直接加载这个数值化矩阵,省去重复编码操作。
在实现上,我更倾向于让credit_class支持两种输入:如果检测到german.data-numeric存在,直接load;否则读取german.data做在线编码。这种做法可以避免每次运行都重新解析文本,节省时间。
function [X, Y] = credit_class() % 优先加载已转换好的数值数据 if exist('german.data-numeric', 'file') data = load('german.data-numeric'); X = data(:, 1:20); Y = data(:, 21); else % 否则从原始german.data解析 lines = readlines('german.data'); num_samples = length(lines); X = zeros(num_samples, 20); Y = zeros(num_samples, 1); for i = 1:num_samples tokens = strsplit(strtrim(lines(i)), ' '); for j = 1:20 token = tokens{j}; if isstrprop(token, 'digit') X(i, j) = str2double(token); else % 调用编码映射函数 X(i, j) = map_code(token); end end Y(i) = str2double(tokens{21}); % 最后一个是标签 end end % 归一化特征 [X, ~] = mapminmax(X', -1, 1); X = X'; % 标签转换为0/1 Y = (Y == 1); end在解析文本时,需要判断某个token是纯数字还是Axx格式,这里用isstrprop(token, 'digit')检查是否全为数字。需要注意的是,readlines是较新版本的MATLAB函数,如果使用旧版本,可以用textscan或fgetl逐行读取。
4.3 迭代3次收敛背后的训练机制
该项目报告里提到迭代次数均为3次,这个现象和trainlm的训练特性直接相关。Levenberg-Marquardt算法在每一步迭代时计算雅可比矩阵的近似二阶导,对1000条样本这种规模的数据,一步就能确定一个较好的搜索方向。加上特征已经归一化到[-1,1],权重更新非常平稳,3次迭代就达到了1e-3的误差阈值。
但这不代表网络没有继续学习的空间。如果把goal改为1e-5,迭代次数会增加到十几甚至几十次,训练集误差继续下降,但测试集正确率可能卡在75%附近不再提升。这说明75%左右就是该模型复杂度下能到达的性能上限,继续压训练误差只会让模型记忆更多噪声,对未知客户的预测没有帮助。
5. 复现74.97%正确率并把它做到80%以上的方向
5.1 复现时的两个前置检查
解压zip后,第一步确认当前目录下存在所有文件,尤其注意german.data-numeric和credit_class.m不能改名。第二步检查MATLAB当前路径,最好把主目录添加到路径中,避免load找不到文件。运行main.m后,如果看到输出平均正确率74.97%,最低73.4%,说明复现成功。
如果你的环境是MATLAB R2020a以下,readlines函数不可用,需要把credit_class.m里的读取方式替换为textscan:
fid = fopen('german.data', 'r'); C = textscan(fid, '%s', 'Delimiter', '\n'); fclose(fid); lines = C{1};替换后逻辑完全一致,不影响最终正确率。
5.2 从75%往上走的三个可操作方向
第一个方向是隐含层节点数的网格搜索。把15改成10、12、18、25分别测试,观察平均正确率变化。在某些随机划分下,隐含层节点取12时,正确率可能会跳到76%以上。虽然涨幅有限,但能让你直观理解模型容量对结果的影响。
第二个方向是交叉验证。固定一个80/20划分,受限于数据集中正负样本比例,测试集结果波动很大。改成5折交叉验证后,每条样本都有机会参与测试,得到的平均正确率会更稳定,也更容易复现。
第三个方向是特征选择。German Credit的20个特征中,有几个属性比如“电话”和“外籍工人”对区分好客户和坏客户贡献很小,可以尝试用reliefF或卡方检验筛选前15个特征再训练。这个动作通常能把正确率抬高1到3个百分点,并且降低过拟合风险。
5.3 一个实用的快速调参脚本片段
下面的代码可以在不修改主流程的情况下,自动扫描不同隐含层节点数和目标误差组合,并把结果记录到表格中:
results = []; hidden_sizes = [10 12 15 18 20]; goals = [1e-2 1e-3 1e-4]; for h = hidden_sizes for g = goals accs = zeros(5, 1); for k = 1:5 rng(k); idx = randperm(size(X, 1)); train_idx = idx(1:800); test_idx = idx(801:end); net = newff(minmax(X_train'), [h 1], {'tansig','purelin'}, 'trainlm'); net.trainParam.goal = g; net.trainParam.epochs = 500; net.trainParam.showWindow = false; [net, ~] = train(net, X_train', Y_train'); Y_pred = sim(net, X_test') > 0.5; accs(k) = 1 - mean(abs(Y_pred - Y_test')); end results = [results; h, g, mean(accs)*100]; end end disp(array2table(results, 'VariableNames', {'Hidden', 'Goal', 'Acc'}));这段代码会把每种组合跑5次取平均,运行时间大约一两分钟。你会看到,goal=1e-2时迭代次数更少,但正确率通常略低;goal=1e-4时训练时间翻倍,正确率也不一定更高。最终选择哪个组合,取决于你是要快速出结果,还是要更好的测试集表现。
本文还有配套的精品资源,点击获取