简介:这是一份基于随机森林算法构建模式识别系统的设计文档,重点展示在MATLAB R2019a中实现银行贷款审批场景的分类预测,适合学习机器学习、决策树集成,以及需要完成相关课程设计或毕业设计的读者。文档从随机森林算法思想讲起,依次给出系统流程图、软硬件环境、数据集描述(1500个训练样本和500个测试样本,7维客户特征)、特征重要性筛选流程,以及利用ID3算法构建多棵决策树并进行投票分类的完整过程。文中还包含带注释的主要程序代码,覆盖randperm随机选特征、randi有放回抽样、决策树训练与统计投票等核心环节,并说明了如何用测试集计算分类准确率来评估模型性能。资源共1个doc文档,压缩包大小154KB,目前已有162人学习下载,适合作为模式识别、机器学习课程设计或随机森林算法实战的参考资料。
1. 随机森林做银行贷款审批:一套能直接跑的三分类MATLAB工程
随机森林在模式识别里的地位,有点像工具箱里的电钻——平时不显眼,拿到标签数据时它是上手最快、最不容易翻车的选择。这套资源把随机森林完整落到银行贷款审批场景里:训练集1500条、测试集500条,七列离散特征,用30棵ID3决策树组成森林,对“同意、再考虑、拒绝”三分类做投票预测,实测准确率91.6%。适合两类人:一类是刚接触集成学习、想读懂随机森林怎么用代码实现的初学者;另一类是手头有离散标签数据、想快速搭一个可解释基线模型的从业者。代码不依赖任何第三方工具包,MATLAB R2019a直接读Excel就能跑,下面我按“原理—代码—踩坑”的顺序把它拆开讲。
2. 随机森林的分类原理与贷款数据集的7个特征:先弄懂再动手
2.1 两个随机源:Bootstrap抽样与特征子空间
随机森林不是简单地把多棵决策树堆在一起投票。它效果好,核心在于两处刻意引入的随机化。
第一个是样本抽样。每棵树训练之前,从原始N个样本里有放回地抽k个(Bootstrap抽样)。有放回意味着同一个样本可能被抽中多次,也可能一次都没被抽中。当N足够大时,一个样本完全不被抽到的概率约为0.368,这些没被抽中的样本就是袋外数据,后续可以用来做内建验证。这套代码里sn=900,也就是每棵树只用1500条里的900条,袋外占比相当高。
第二个是特征子空间。每棵树在分裂时,不是从全部M个特征里挑最优分裂属性,而是先从M个里随机选m个(m远小于M),再在这m个里按信息增益选最好的。树与树之间看的“视角”不同,相关性被压低,投票结果的方差才会小,泛化能力才稳。这套代码的M=6,m被写死为5,也就是randperm(6,5),每个节点只有5个特征参与竞争。
注意:这里的Feature=randperm(6,5)是在每棵树开始构建时随机一次,整棵树共用这5个特征。理论上的随机森林在每个节点都重新抽特征子空间,代码做的是“每棵树抽一次”,效果差别不大,但你调参时要知道自己改的是哪一层。
2.2 贷款数据集的7个特征:每一列的含义与编码边界
数据集是银行是否同意贷款的分类预测,总共2000条样本,列全是离散标签值。前6列是候选特征,第7列是标签,做的是三分类。
| 列号 | 含义 | 编码 | 取值 |
|---|---|---|---|
| 1 | 年龄 | 1青年,2中年,3老年 | 1/2/3 |
| 2 | 收入水平 | 1一般,2最高,0低 | 0/1/2 |
| 3 | 是否有房 | 1有,0无 | 0/1 |
| 4 | 信用等级 | 1很好,2好,3一般 | 1/2/3 |
| 5 | 性别 | 1男,0女 | 0/1 |
| 6 | 居住地 | 1城市,0农村 | 0/1 |
| 7 | 标签 | 1同意贷款,2还需考虑,3不同意 | 1/2/3 |
这套编码有个隐藏风险:收入“低”是0,“无房”是0,“女”是0,“农村”是0。在ID3这种基于符号划分的算法里,0只是离散符号,不参与距离计算,所以安全。但如果同样这批数据拿去做KNN或SVM,编码0就会造成“低收入=女性=农村”的假邻近,必须重编码或做one-hot。
第7列是三分类标签,代码里所有熵计算都按3类处理,CN_sta函数维护的是3个计数器,而不是二分类的1个。如果你想把任务改成“是否同意”的二分类,需要把所有“2还需考虑”合并成“3不同意”,或者直接删除这部分样本,而不是简单地把标签大于1的置为0。
2.3 特征提取与重要性排序:为什么固定保留5个特征
原文档1.4节描述了一套完整的特征选择流程:计算每个特征的重要性,按降序排序,确定剔除比例,反复剔除直到剩下m个特征,每个特征集对应一个袋外误差率,最后选袋外误差率最低的特征集。
代码里没有把整套特征选择过程写全,而是直接定义了m=5,也就是从6个候选特征里保留5个参与建模,等于固定剔除1个特征。如果你想补全这个特征选择环节,可以在主程序外层套一个遍历:对6个特征做2^6=64种组合,每种组合重训一次森林、记录准确率,最后挑准确率最高的组合。6个特征的组合数只有64,穷举完全不费时间,比“重要性排序+比例剔除”更可靠。
特征重要性本身在随机森林里有几种算法,最常用的是不纯度减少:一棵树在某个特征上分裂时带来的信息增益越大,说明该特征对分类贡献越大,把森林里所有树的增益累加平均,就得到重要性分数。注意这套代码没单独输出重要性指标,如果你需要,可以在TreeNode每次算出Gain时累加到对应特征的计数器里。
3. ID3决策树:信息增益怎么算,递归分裂如何停下来
3.1 熵与信息增益:三个公式讲透分裂依据
ID3选择分裂属性的依据是信息增益,信息增益的来源是信息熵。先把三个公式摆出来。
数据集整体熵: I = -Σp(i) × log2(p(i)),i遍历全部类别
p(i)是第i类样本在集合中的占比。这套数据有3类,所以循环3次。代码里的Exp函数就是它的实现:
function I=Exp(CN,A) CNRowNum=CN_sta(CN,A); n=length(A); I=0; for i=1:3 if CNRowNum(i)>0 P(i)=CNRowNum(i)/n; I=I-P(i)*log2(P(i)); end end end逻辑说明:CN是全体样本的第7列标签,A是参与计算的行号集合。为什么传行号而不是直接对整列算?因为递归分裂后每个子集是原样本的子集合,A就是子集合的行索引。CNRowNum通过CN_sta统计出A对应行里三类计数,n是子集大小。某个类计数为0时跳过,防止log2(0)报错。
然后是条件熵,按某个特征A分裂后各分支熵的加权平均:
avg_entropy = Σ(|RowNum_i| / n) × Exp(CN, RowNum_i)
代码实现:
function entropy=avg_entropy(Attri, A, CN) k=0; entropy=0; n=length(A); I=Exp(CN,A); [PValue,RowNum]=type_sta(Attri,A); for i=1:length(PValue) CI=Exp(CN, RowNum{i}); entropy=entropy - length(RowNum{i})/n*CI; end end参数说明:Attri是DValue矩阵的某一列,也就是某个特征在所有样本上的取值。type_sta按值归类,找出这一列里有几种取值、每种取值对应哪些行号。每一类子集的熵按样本占比加权累加,得到分裂后的不纯度。
信息增益就是两者之差:
Gain = I(分裂前整体熵) - avg_entropy(分裂后加权熵)
TreeNode里对ClassPNum中的每个特征算一遍Gain,选增益最大的特征作为当前节点的分裂属性。这一步是整套决策树构建的核心。
3.2 递归终止的4种情形:TreeNode的收尾逻辑
TreeNode是决策树生成器的核心递归函数,接收5个参数:DValue全部特征数据、CN标签、A参与行号、ClassPNum剩余可用特征编号、m父节点编号。每次递归先检查终止条件。
第一种:isempty(DValue),样本集为空,直接返回空节点。这是防御性写法,递归过程中RowNum{i}有可能为空,需要兜底。
第二种:isempty(ClassPNum),可用特征已经用完,没法再分裂。用find_most选出当前子集中占比最高的类别作为叶子节点。find_most内部用CN_sta统计三类计数,取最大值索引,返回类别名字符串。
第三种:length(find(CNRowNum==0))>=2,三类中两类计数为0,说明当前子集样本都属于同一类。node直接记录CN(A(1)),变成纯叶子。
第四种:max(Gain)<=0,所有特征的信息增益都非正,继续分裂没有意义,用find_most收尾。
这4种终止条件覆盖了ID3的标准停止策略。但要注意,这套代码没有预剪枝——没有最大深度限制、没有叶子最小样本数。只要信息增益为正,树就会一直长到训练集被分纯。这套数据量小,树深有限,过拟合风险可控;如果你换到更大的数据集,一定要自己加剪枝条件。
3.3 为什么这里用ID3而不是C4.5或CART
现在的工业实践里,C4.5(增益率)和CART(基尼系数)更常见,但这套工程选ID3在数据上是合理的。
ID3只能处理离散属性,而这套数据6个特征全部是整数编码,天然匹配。ID3的已知缺点是偏好取值多的属性,但这套数据每个特征最多3个取值,偏好问题不明显。C4.5在ID3基础上加了连续属性离散化、增益率修正和剪枝,代价是代码复杂度明显上升。CART用基尼系数,生成的是二叉树,节点分裂逻辑和这套代码的type_sta多分支逻辑不兼容。
所以保留ID3是正确的。但你要知道它的边界:一旦后续在数据里加入“收入金额”“征信分数”这类连续特征,ID3就会非常痛苦,到时候再改成C4.5或CART不迟。在MATLAB里想快速验证CART效果,可以用自带的fitctree函数,但要注意那会放弃这套手写ID3的训练流程,相当于重新搭一套。
4. 在MATLAB R2019a里把随机森林跑起来:代码逐段拆解
4.1 主程序:30棵树、每棵900个样本,这两个参数怎么理解
先看主程序的第一段:
clear all; clc; rnode=cell(3,1); %3*1单元数组,存每棵树的节点编号 rchild_value=cell(3,1); %3*1单元数组,存每棵树各节点的分裂属性值 rchild_node_num=cell(3,1); %3*1单元数组,存每棵树各节点的子节点编号 sn=900; %每棵树从样本集有放回抽取sn个样本 tn=30; %森林中决策树的棵数 S=xlsread('loan_train.xls'); %读训练集三个cell数组用来存放整个随机森林:rnode{j,1}是第j棵树的节点列表,rchild_value{j,1}是第j棵树各节点的分支取值,rchild_node_num{j,1}是第j棵树各节点的子节点编号。注意cell初始化为3行,而tn=30,当j超过3时MATLAB会自动扩展数组,不会报错,但如果你在循环前就用rnode{30,1}预分配,代码会更规范。
训练循环是整份资源的灵魂:
for j=1:tn Feature=randperm(6,5); %随机选取5个特征参与本棵树构建 Sample_num=randi([1,1500],1,sn); %从1至1500内随机抽取sn个样本,1*sn矩阵 SData=S(Sample_num,:); %一棵树的训练集,有放回抽样 [node,child_value,child_node_num]=ID3(SData,Feature); rnode{j,1}=node; rchild_value{j,1}=child_value; rchild_node_num{j,1}=child_node_num; end逻辑说明:每次循环构建一棵树。Feature用randperm从6个特征中随机选5个,这5个特征在这棵树整个生命周期内固定参与分裂。Sample_num用randi从1到1500之间随机抽900次,生成1×900的行向量,这就是Bootstrap抽样——放回抽取,同一行可能重复出现。SData用索引用行抽取,得到一棵树的训练子集。然后调ID3,返回三个数组,按行存入cell。
参数说明:sn=900意味着每棵树只用60%的训练样本,剩下约40%未参与该树训练,它们就是袋外样本。如果你想算袋外误差,可以在训练结束后用“没被抽到的行号”做验证,但原代码没算。tn=30的森林规模偏小,通常提升到100—200棵结果会更稳定,但训练时间也会线性增长。
测试循环:
T=xlsread('loan_test.xls'); TData=roundn(T(:,1:end-1),-1); %四舍五入保留一位小数 len=length(TData(:,1)); %测试样本数 type=zeros(len,1); for j=1:len [type(j)]=statistics(tn,rnode,rchild_value,rchild_node_num,TData(j,:)); end xlswrite('loan_result.xls',[T type]); %输出测试结果 gd = T(:,end); count = sum(type==gd); fprintf('共有%d个样本,判断正确的有%d\n 准确率为:百分之%s\n',len,count,count/len*100);测试时对特征做roundn,保留一位小数。逻辑上是为了把浮点比较变成更稳定的值比较。随后对每个测试样本,statistics让30棵树分别投票,取票数最多的类别,写回结果文件并算准确率。
4.2 ID3与TreeNode:三个全局数组如何存下一棵树
ID3函数:
function [node,child_value,child_node_num]=ID3(S,Feature) global node child_value child_node_num DValue=S(:,1:6); %取前6列特征 DValue=roundn(DValue,-1); %四舍五入 CN=S(:,7); %取第7列标签 CN=num2str(CN); %标签转字符串 for i=1:length(CN) A(i)=i; %初始化行号索引 end [Feature,~]=sort(Feature); ClassPNum=Feature; CLASSPNUM=[1 2 3 4 5 6]; [CHA,~]=setdiff(CLASSPNUM,ClassPNum); DValue(:,CHA)=0; %把没用到的特征置0 m=0; [node,child_value,child_node_num]=TreeNode(DValue,CN,A,ClassPNum,m); end逻辑说明:ID3先把标签列从数值转成字符串,因为叶子节点find_most返回的是字符串形式的类别名,后续投票比较用strcmp。然后把Feature排序,整理出当前树可用的特征编号ClassPNum。setdiff取差集,找出6个特征里没被选中的那1个,把DValue对应列整体置0标记为“本树不可用”。最后以m=0为根节点递归建树。
用全局变量存树是这段代码比较古老但实用的写法。node、child_value、child_node_num三个全局数组,配合TreeNode递归时不断append,最终形成一棵完整决策树。如果你自己重构,建议改成函数内局部变量加返回值传递,能避免全局变量在多次调用时残留。
TreeNode递归的核心部分:
function [node,child_value,child_node_num]=TreeNode(DValue,CN,A,ClassPNum,m) global node child_value child_node_num n=length(node); if m>0 k=length(child_node_num{m}); child_node_num{m}(k+1)=n+1; %把本节点编号挂到父节点的子节点列表 end if isempty(DValue) node{n+1}=[]; child_value{n+1}=[]; child_node_num{n+1}=[]; return; end if isempty(ClassPNum) node{n+1}=find_most(CN,A); child_value{n+1}=[]; child_node_num{n+1}=[]; return; end CNRowNum=CN_sta(CN,A); if length(find(CNRowNum==0))>=2 node{n+1}=CN(A(1)); child_value{n+1}=[]; child_node_num{n+1}=[]; return; else I=Exp(CN,A); for i=1:length(ClassPNum) Entropy(i)=avg_entropy(DValue(:,ClassPNum(i)),A,CN); Gain(i)=I-Entropy(i); end if max(Gain)<=0 node{n+1}=find_most(CN,A); child_value{n+1}=[]; child_node_num{n+1}=[]; return; else maxG=find(Gain==max(Gain)); [PValue,RowNum]=type_sta(DValue(:,ClassPNum(maxG(1))),A); node{n+1}=ClassPNum(maxG(1)); child_value{n+1}=PValue; child_node_num{n+1}=[]; ClassPNum(maxG)=[]; for i=1:length(PValue) [node,child_value,child_node_num]=TreeNode(DValue,CN,RowNum{i},ClassPNum,n+1); end return; end end end逻辑说明:当前节点先判断能不能直接收尾,不能就开始算信息增益。Gain最大值的特征作为分裂属性,按该特征的不同取值把样本划分成多个子集RowNum{i},每个子集递归建子树。node记录节点编号或叶子类别,child_value记录内部节点每个分支的特征值,child_node_num记录每个分支对应的子节点编号。三层数组配合,形成一棵可查询的树。
4.3 statistics与vote:30棵树对一条样本如何投票
statistics函数:
function [type] = statistics(tn,rnode,rchild_value,rchild_node_num,PValue) TypeName={'1','2','3'}; TypeNum=[0 0 0]; for i=1:tn [type]=vote(rnode,rchild_value,rchild_node_num,PValue,i); if strcmp(type,TypeName{1}) TypeNum(1)=TypeNum(1)+1; elseif strcmp(type,TypeName{2}) TypeNum(2)=TypeNum(2)+1; else TypeNum(3)=TypeNum(3)+1; end end maxn=find(TypeNum==max(TypeNum)); type=str2num(TypeName{maxn(1)}); end逻辑说明:对第i棵树调用vote,让这棵树返回一个类别字符串,然后统计30棵树里三类票数,取最高票类别作为最终分类。maxn(1)取最大值的第一个索引,避免并列时数组越界。
vote函数:
function [type] = vote(rnode,rchild_value,rchild_node_num,PValue,j) n=1; %从根节点开始 k=0; while ~isempty(rchild_node_num{j,1}{n}) for i=1:length(rchild_value{j,1}{n}) if PValue(rnode{j,1}{n})==rchild_value{j,1}{n}(i) n=rchild_node_num{j,1}{n}(i); k=0; break; end end if i==length(rchild_value{j,1}{n}) PValue(rnode{j,1}{n})=PValue(rnode{j,1}{n})+0.1*k; PValue=roundn(PValue,-1); end k=(-1)^k*(abs(k)+1); end type=rnode{j,1}{n}; end逻辑说明:从根节点开始,把测试样本当前节点的特征值与每个分支值比对。匹配则进入子节点;不匹配则用0.1*k做小幅偏移,再roundn回来,希望偏移后能匹配上某个分支。k的变化序列是0、1、-2、3、-4,交错正负偏移。
这个设计的初衷是防御“测试样本遇到训练时没见过的取值”。问题在于这是离散整数数据,单一分支值有0和1两类,偏移0.1需要很多次才能从0抖动到1,在最坏情况下循环次数非常高,而且强行微调特征值去匹配另一个分支,本质上是一种近似容错,并不符合离散分类语义。第5章会展开这个坑。
4.4 结果输出与准确率计算:从loan_result.xls反推模型好坏
运行结束后loan_result.xls的每一行是测试样本的原始7列再加第8列预测类别。gd=T(:,end)取真实标签,与预测结果逐行比较,sum(type==gd)统计正确数,最后算出准确率。复现结果是458/500,即91.6%。
注意:这是单次运行的结果。Bootstrap抽样和特征选择都是随机的,每次运行准确率会有小幅波动,范围大概在90%到93%之间。判断模型好坏之前,先固定随机种子。
5. 复现这套代码的5个坑:从精度截断到投票抖动
5.1 坑一:roundn保留一位小数,对整数特征无害,但会坑连续值
现象:代码在训练和测试前都对特征执行roundn(..., -1),也就是保留一位小数。我第一次照跑,没觉得有问题,准确率稳定在91.6%。后来把特征换成原始未取整数据重跑,发现结果变化很小。
原因:这套数据的特征全是整数,roundn对整数没有任何影响。但如果把代码套到自己的连续特征数据上,比如收入金额12.34,roundn会强行改成12.30,连续值的决策边界被粗糙化,ID3无法区分12.34和12.30的差异,熵计算结果会被污染。
解决:如果特征本身就是整数编码,直接删掉roundn这行;如果特征里有连续值,不要用roundn粗暴截断,换成C4.5的连续属性离散化逻辑,或者先把连续值按分位数分成几个区间再喂给ID3。
5.2 坑二:setdiff置0不等于禁用特征,想改节点级随机时容易翻车
现象:我原以为Feature=randperm(6,5)之后,没被选中的特征就彻底退出了这棵树。为了验证,我打印TreeNode的分裂特征编号,结果发现自己想改成“每个节点重新随机抽5个特征”时,原来的置0逻辑让DValue里某些列全是0,新逻辑根本没法跑。
原因:DValue是全局共享的数据矩阵,ID3在入口一次性把没选中的列全置0了,影响会扩散到所有后续递归。置0并没有真正删除特征列,只是把值改成常量;而真正控制某特征参与分裂的是ClassPNum的传递。如果你想改成节点级随机,置0的列会在数据层面干扰统计。
解决:删掉setdiff置0这段代码,改成索引过滤——TreeNode计算avg_entropy时只用ClassPNum里的编号去DValue取列,不主动修改DValue。这样既保留原始特征值,也为后续动态特征选择留下扩展空间。
5.3 坑三:vote的“最近值”抖动可能导致高循环或错分
现象:有一次调试,程序卡在vote函数里很久不返回。检查发现测试样本在某棵树上遇到一个节点,特征取到了树里没有的分支值,vote进入抖动逻辑,k从0开始按+1、-2、+3、-4交替变化,而分支值是整数,0.1的步长要抖动很多次才可能命中。
原因:这套数据是离散整数标签,树的分支是0/1/2这样的固定值。vote的0.1*k抖动逻辑是为连续值容错设计的,根本不适合离散特征。更麻烦的是,即使抖动能命中,也等于是强行把特征值改到另一个分支上去匹配,分类结果可能错误。
解决:我在复现时给它加了最大重试次数,比如100次,超过就放弃该树的投票,返回当前节点子集最常见的类别作为该树结果。这样至少保证整个流程稳定,不会因为一棵树的异常拖住全流程。
5.4 坑四:没有固定随机种子,90%到93%的波动无法定位
现象:第一次跑91.6%,第二次90.4%,第三次91.0%。同一份数据、同一套代码,结果却在小幅波动。
原因:randperm和randi都在消耗随机数流,MATLAB每次启动的随机种子默认不同。随机森林本身有随机性,但工程上必须固定种子,否则你没法判断准确率变化是参数改动引起的,还是随机波动。
解决:在主程序第一行加rng(42),42可以换成任意整数。数据不变、参数不变时,每次运行结果完全一致。调参前先固定种子,否则你只是在跟随机数搏斗。
5.5 坑五:xlswrite与Excel路径,以及标签列格式统一
现象:在另一台机器上运行时,xlswrite报错,或者生成的loan_result.xls在中文路径下打不开。
原因:xlswrite依赖Windows COM组件,Excel版本和MATLAB版本不匹配时容易挂。另外,如果原表格的第7列是文本形式的类别名,而type是数字,直接拼接[T type]会导致类型不一致。
解决:xlswrite替换成writematrix或writecell;训练集和测试集放在与脚本同一目录,路径保持纯英文无空格;标签列统一转成double再拼接写入,避免字符串与数字混排。
6. 把准确率从91.6%往上顶:调参顺序与一个验证习惯
6.1 参数扫描的先后顺序
三个核心参数:tn树棵数、sn每棵树的样本数、m每棵树参与分裂的特征数。
我的建议是先固定m,扫描tn,从30加到100、200。如果100棵比30棵明显提升,说明之前欠拟合;如果提升很小或下降,说明30棵已经够,问题在别处。然后固定tn,扫描sn,试900、1200、1500。sn越大,每棵树训练越充分,但多样性下降;sn越小,单棵树越弱,整体方差变大。最后再动m,从5降到3或4。m降低后单棵树准确率必然下降,但森林整体准确率可能持平甚至略升,因为投票在综合更独立的错误模式;如果降太狠导致准确率明显跌,就回到5。
常见的玄学操作是只加树的数量,把tn从30改成300,发现准确率原地不动,就以为随机森林失效了。其实树多了之后性能收敛,继续堆树只增加训练时间,真正突破要靠特征工程和样本质量。
6.2 一个验证习惯:每次跑完先看混淆矩阵
单看91.6%远远不够。我后来每次改动都至少看两样:混淆矩阵,以及每一类的查准率和查全率。loan_result.xls前7列是原始特征和标签,第8列是预测,在MATLAB里快速统计:
pred = type; true = T(:,end); C = confusionmat(true, pred); disp(C);C的行是真实类别,列是预测类别。对角线数字越大越好;如果某一行对角线占比明显低于其他行,说明模型对那个类别的识别能力弱,回头查该类在训练集里的分布是否太稀疏。
我的习惯是在脚本开头写一行配置注释,固定每次实验的参数快照:
% cfg: tn=30, sn=900, m=5, seed=42, acc=91.6% rng(42);这个习惯救过我很多次。有一阵连续改特征编码,准确率都稳定在92%附近,我差点以为编码没改对,后来固定种子才发现只是随机波动的假象。从那以后我每次跑随机森林都强制走固定seed、记录cfg、看混淆矩阵这三步,希望帮到你。
本文还有配套的精品资源,点击获取