简介一份面向机器学习初学者的随机森林模式识别系统设计与实现项目文档以银行贷款审批为应用场景解决依据年龄、收入、住房、信用、性别、居住地等七个特征判断是否同意贷款的识别问题。文档系统阐述随机森林算法思想——通过有放回抽样构建多棵决策树并随机选取特征分裂最终以投票方式集成预测结果同时介绍基于MATLAB R2019a环境、Windows10平台的实现过程包含一千五百个训练样本与五百个测试样本的数据集描述、特征重要性排序与剔除策略、ID3算法构建决策树的核心步骤以及最终的分类准确率评估方法。资源包共一个doc文件容量154KB已有162人学习。读者可从中获得完整的系统流程图、特征提取过程、程序主要代码和结果分析思路代码中randperm随机选取特征、randi随机抽取样本、statistics投票统计等关键模块均有体现便于复现实验并迁移至其他模式识别或分类任务适合作为毕业设计、课程项目或企业业务风控建模的参考模板。1. 一套把随机森林跑在贷款审批上的完整 MATLAB 实现看到「基于随机森林算法的模式识别系统」这个标题第一反应是又一套理论堆砌的演示代码但把源码跑完发现它确实把随机森林的完整链路——从特征重要性评估、ID3 决策树构建、有放回抽样、随机特征选择到多棵树投票和准确率统计——全部落成了可执行的 MATLAB 代码。系统做的事情很具体根据客户的年龄、收入、房产、信用、性别和居住地六个离散特征预测银行是否同意贷款输出「同意 / 还需考虑 / 不同意」三分类结果在 500 条测试数据上跑出了 91.6% 的准确率。适合两类人一是正在做模式识别课程设计、需要一套能跑通还能讲清楚原理的参考实现二是想理解随机森林内部机制、打算移植成 Python 或其他语言的开发者。整套代码对数据格式和运行环境要求很低Windows 10 MATLAB R2019a 就能直接跑。2. 算法与系统设计为什么用 ID3 构建随机森林以及整体模块怎么划分2.1 随机森林的核心思想随机性来自哪里随机森林的本质是「装袋 随机子空间」的组合。装袋体现在样本层面每棵决策树的训练集是从原始 1500 条样本中有放回地抽取 900 条这意味着同一棵树可能重复看到同一条样本而另一些样本始终没被抽到。随机子空间体现在特征层面当某棵决策树在某个节点需要分裂时并不是从全部 6 个特征里挑信息增益最大的那个而是先从 6 个特征中随机选 5 个源码里是Featurerandperm(6,5)再在这 5 个候选特征里按信息增益选出最优分裂属性。这两层随机叠加保证树与树之间足够「不相关」最终投票结果才能平稳。值得注意的一点是这份实现和主流随机森林有一个差异。标准随机森林通常用 CART 树基尼系数而这里用的是 ID3信息增益。这是有意为之的——系统的标签只有三个离散值特征全是编码后的整数如 1/2/3 代表年龄段完全符合 ID3 只能处理离散属性的约束。每棵树的内部结构通过三个全局 cell 数组rnode、rchild_value、rchild_node_num记录树与树之间互相独立正好对应了随机森林「多棵树独立生长、最后集体投票」的并行思想。2.2 系统的四个模块划分从clear all; clc;开始往下读整个系统在结构上可以切为四个模块四个模块在代码里也是按顺序执行的。第一个模块是参数初始化。sn900定义每棵树抽多少条样本tn30定义森林里长多少棵树rnode、rchild_value、rchild_node_num三个 3×1 的 cell 数组用来存放森林中每一棵树的节点信息。这里注意代码里写的是rnodecell(3,1)看起来是 3 行但实际运行时rnode{j,1}node会把第 j 棵树的完整节点数组塞进第 j 行所以这里初始的 cell 维度并不关键动态扩展也能工作。第二个模块是训练阶段。循环for j1:tn每轮做三件事用randperm(6,5)随机选特征用randi([1,1500],1,sn)随机抽取 900 条样本允许重复调用ID3(SData,Feature)构建一棵完整的决策树并存入三个 cell 数组。这个循环结束后森林里就有 30 棵结构互不相同的决策树。第三个模块是测试阶段。读取loan_test.xls把前 6 列特征用roundn(T(:,1:end-1),-1)保留一位小数然后逐行调用statistics函数。这个函数内部会遍历全部 30 棵树、让每棵树对当前样本投一票、按少数服从多数的原则输出最终类别。第四个模块是结果输出与评估。xlswrite(loan_result.xls,[T type])把原始测试数据和预测类别写回 Excel然后与真实标签gdT(:,end)比对统计判断正确的数量并输出准确率。整体逻辑清晰训练、预测、评估三个阶段完全解耦改参数或换数据集都不需要动主体代码。2.3 特征重要性评估与特征剔除的流程系统在第 1.4 节描述了特征提取环节先计算每个特征的重要性按降序排序再按设定的剔除比例删掉不重要的特征得到一个新特征集然后用这个新特征集重新训练并计算袋外误差率重复直到剩下预设的 m 个特征。实际代码里这一步没有完整实现训练阶段直接用randperm(6,5)固定每轮随机取 5 个特征相当于把「特征重要性评估」交给了随机森林自身的随机选择机制。从工程角度讲这不算偷工减料——随机森林本身就具备特征重要性评估能力在每棵树的节点分裂时被选中且信息增益高的特征会自然获得更高的「出场率」。如果你想在这份代码上补全特征剔除逻辑可以在训练完成后统计 30 棵树里每个特征被选作分裂节点的次数按次数排序就能得到一个粗粒度的特征重要性排序。这个数字和信息增益累计值基本成正比用来做特征筛选完全够用。3. 核心代码拆解ID3 决策树、森林构建与投票统计的三段式实现3.1 训练主循环随机抽样本、随机选特征、训练一棵树先看训练阶段的主循环。这段代码是整个系统的入口理解它就知道随机森林的两层随机性是怎么注入的。sn900; % 每棵树随机抽取的样本数 tn30; % 森林中决策树的棵数 Sxlsread(loan_train.xls); % 读取训练集 for j1:tn % 随机选取 5 个特征从 6 个特征中不含标签列 Featurerandperm(6,5); % 从 1 至 1500 内随机抽取 sn 个样本允许重复有放回抽样 Sample_numrandi([1,1500],1,sn); SDataS(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这里randperm(6,5)的作用是从 1 到 6 的整数中无放回地随机选 5 个返回一个 1×5 的向量代表「本次允许用来分裂的特征编号」。randi([1,1500],1,sn)则是生成 900 个 1 到 1500 之间的随机整数由于允许重复所以等效于有放回抽样。SData 是选出来的 900 条样本特征只有 6 列标签在第 7 列。ID3(SData,Feature)返回三个值node 是决策树的节点数组每个元素记录该节点是「分裂特征编号」还是「类别标签」child_value 记录每个节点的分支取值child_node_num 记录每个节点的子节点序号。这三个数组共同构成一棵完整的树分别存入rnode{j,1}、rchild_value{j,1}、rchild_node_num{j,1}。循环 30 次后森林构建完成。参数调整上tn越大森林越稳但训练时间线性增长sn越接近 1500单棵树的训练集和全集差异越小树之间的多样性会下降。这份代码里 sn900 大约是总样本的 60%属于比较合理的装袋比例。3.2 ID3 递归建树从根到叶的四个终止条件ID3函数本身只做了一些准备工作把特征编号排序、把未选中的特征列置 0、初始化全局变量然后调用核心递归函数TreeNode。这里有个细节值得注意DValue(:,CHA)0把未被选中的特征列整体置零是为了在计算信息增益时让这些列不产生实际影响避免复杂的掩码逻辑。TreeNode是决策树生长的心脏递归终止有四个条件每个条件对应决策树理论中的标准情形% 条件 1样本集为空树为空 if isempty(DValue) node{n1}[]; child_value{n1}[]; child_node_num{n1}[]; return; end % 条件 2剩余属性为空取多数类作为叶节点 if isempty(ClassPNum) node{n1}find_most(CN,A); child_value{n1}[]; child_node_num{n1}[]; return; end % 条件 3样本都属于同一类该类别即为叶节点 CNRowNumCN_sta(CN, A); if length(find(CNRowNum0))2 node{n1}CN(A(1)); child_value{n1}[]; child_node_num{n1}[]; return; end条件 1 是空集保护理论上不会触发但递归处理时必须写上条件 2 处理的是「所有特征都用完了但样本仍不是同一类」的情况此时用find_most找出样本中出现最多的类别作为叶节点条件 3 是最理想的情况——当前子集里所有样本的标签相同直接把这个标签存为叶子节点。如果三个条件都不满足就说明当前子集是混合类别需要继续分裂。分裂属性的选择逻辑是IExp(CN,A); % 当前子集的经验熵 for i1:length(ClassPNum) Entropy(i)avg_entropy(DValue(:,ClassPNum(i)), A, CN); Gain(i)I-Entropy(i); end % 如果所有信息增益都小于等于 0取多数类 if max(Gain)0 node{n1}find_most(CN,A); return; else maxGfind(Gainmax(Gain)); [PValue, RowNum]type_sta(DValue(:,ClassPNum(maxG(1))), A); node{n1}ClassPNum(maxG(1)); % 记录分裂特征编号 child_value{n1}PValue; % 记录各分支的取值 child_node_num{n1}[]; ClassPNum(maxG)[]; % 删除已用特征 for i1:length(PValue) [node,child_value,child_node_num]TreeNode(... DValue, CN, RowNum{i}, ClassPNum, n1); end end这段逻辑就是 ID3 的全部核心计算当前数据集的经验熵计算每个候选特征的条件熵两者相减得到信息增益选增益最大的特征作为当前节点的分裂属性。然后按该特征的每个取值把数据集切分成多个子集递归地对每个子集继续建树。avg_entropy和Exp两个辅助函数分别实现了条件熵和经验熵的数学公式——注意它们用的都是log2这正是信息熵的标准底数。3.3 单棵树投票与森林汇总statistics vote 双层结构测试阶段同样分两层statistics负责汇总所有树的投票结果vote负责单棵树对单个样本的分类。function [type] statistics(tn, rnode, rchild_value, rchild_node_num, PValue) TypeName{1,2,3}; TypeNum[0 0 0]; for i1: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 maxnfind(TypeNummax(TypeNum)); typestr2num(TypeName{maxn(1)}); endstatistics遍历森林中全部 tn 棵树让每棵树对同一个测试样本输出一个类别统计三类各自获得的票数得票最多的类别作为最终预测结果。voten1; % 从根节点开始查 while ~isempty(rchild_node_num{j,1}{n}) for i1:length(rchild_value{j,1}{n}) if PValue(rnode{j,1}{n})rchild_value{j,1}{n}(i) nrchild_node_num{j,1}{n}(i); break; end end end typernode{j,1}{n};vote 的查找逻辑是从根节点出发读取当前节点的分裂特征编号 rnode{j,1}{n}取出测试样本在该特征上的取值与当前节点的各分支取值 rchild_value{j,1}{n}(i) 比较找到匹配的分支就跳到对应的子节点 rchild_node_num{j,1}{n}(i)。一直走到叶子节点rchild_node_num{j,1}{n} 为空叶子节点里存的就是这棵树的预测类别。 这段代码还藏了一个对离散特征的兼容处理如果测试样本在某个特征上的取值没有对应的分支比如训练集里该特征没出现某个值代码会用邻近值兜底——把当前值加 0.1 的倍数然后再取整对齐。这算是处理「训练分布和测试分布不完全一致」时的一种朴素策略实际效果在边界样本上可能不太稳定但整体对准确率影响不大。 ## 4. 运行过程与数据解读从 loan_train.xls 到 loan_result.xls 的完整链路 ### 4.1 数据集的格式与含义 运行整个系统前先要搞清楚 xls 文件里到底存了什么。训练集 loan_train.xls 共 1500 条样本测试集 loan_test.xls 共 500 条样本每行是一条贷款申请记录。每条记录有 7 列前 6 列是特征第 7 列是标签。 | 列号 | 含义 | 编码规则 | |------|------|----------| | 第 1 列 | 年龄 | 1青年2中年3老年 | | 第 2 列 | 收入水平 | 1一般2最高0低 | | 第 3 列 | 是否有房 | 1有0没有 | | 第 4 列 | 信用等级 | 1很好2好3一般 | | 第 5 列 | 性别 | 1男0女 | | 第 6 列 | 居住地 | 1城市0农村 | | 第 7 列 | 贷款决策 | 1同意贷款2还需考虑3不同意 | 所有特征都是离散标签值这正是 ID3 能直接处理的数据形态。需要特别说明的是第 2 列的收入编码它用了 0/1/2 三个值和传统的 0/1 二值编码不完全一致读数据时不要混淆。如果你要换成自己的数据集只要保证前 6 列都是整数编码、第 7 列是 1~3 的类别标签代码基本不用改。运行主程序 main.m 后系统会依次完成训练森林、测试样本、写出结果并在命令窗口打印准确率。 ### 4.2 运行结果与实际输出 代码跑完后命令窗口输出的信息是共有 500 个样本判断正确的有 458 个准确率为百分之 91.6。每次运行结果会有小幅度波动因为 randperm 和 randi 都是随机函数没有固定随机种子但 30 棵树投票的集成效应会让准确率稳定在 90% 上下波动范围一般在 1%~2% 以内。 同时程序会在当前目录生成 loan_result.xls 文件内容格式是测试集原始 6 个特征 预测类别第 7 列。你可以把它和 loan_test.xls 的真实标签做逐行对比定位哪些样本被分错了。从决策树的结构来看rnode、rchild_value、rchild_node_num 三个数组保存了全部 30 棵树的结构信息——rnode{j,1}{1} 是第 j 棵树的根节点分裂特征沿着 child_value{j,1}{1} 的值和 child_node_num{j,1}{1} 的序号可以完整还原树形这对阅读和调试决策树的行为很有帮助。 ### 4.3 运行前检查清单 跑这个项目翻车率最高的地方往往不是算法本身而是环境细节。运行前建议按这个清单检查一遍。 - MATLAB 当前路径必须包含 main.m 和两个 xls 文件否则 xlsread 会报「文件不存在」错误。 - 确保 xls 文件中的数据没有表头行有表头会让第一行被当成样本读入导致特征维度错乱。 - 首次运行建议在命令行执行 clear all 清空工作区避免上一次运行遗留的全局变量 node、child_value、child_node_num 干扰新一次训练。 - 如果命令窗口报 Undefined function ID3说明 ID3.m 文件不在当前搜索路径上需要检查文件是否在同一目录。 ## 5. 避坑指南训练随机森林时最容易翻车的六个细节 ### 5.1 全局变量污染导致决策树错乱 现象第二次运行主程序时准确率骤降甚至直接报错索引超出范围。 原因ID3 和 TreeNode 里使用 global node child_value child_node_num 三个全局变量。第一次运行结束后这些全局变量还留在工作区里。第二次运行时如果没有先 clear新树会追加在旧树数组后面导致节点索引错配。 解决主程序第一行强制 clear all; clc; 清空全部变量。如果你把主程序封装成函数记得在每个函数入口重新声明并初始化全局变量或者改成用 persistent 变量配合手动重置。 ### 5.2 randi 抽到重复样本不能当成 bug 现象检查训练集时发现某条样本在 SData 里出现了两次怀疑代码写错了。 原因随机森林的装袋策略就是有放回抽样randi([1,1500],1,sn) 生成的 900 个索引本身就允许重复。这种重复正是随机森林泛化能力的来源——每棵树看过不同的样本组合投票才能互补。 解决不用改。如果你想验证「无放回抽样」的效果可以把 randi 换成 randsample(1500, sn, false)但这不是随机森林的标准做法不建议在正式实验里这样改。 ### 5.3 测试集特征值不在训练集范围内 现象程序运行时卡在 vote 函数里或某个样本被判错排查发现测试样本某列特征值在训练集中从未出现过。 原因决策树的每个分支是对应特征的具体取值。ID3 只在训练集的取值范围内生长分支测试集一旦出现新值比如信用等级出现了 4child_value 里找不到匹配分支代码只能靠加 0.1 的邻近值策略硬凑。 解决这是 ID3 的天然缺陷。工程上可以在数据预处理阶段检查测试集取值集合是否包含于训练集取值集合或者把这类未知值直接映射为「训练集中该特征出现频率最高的值」。如果数据量足够更推荐的做法是换用能处理连续属性的 C4.5 或 CART 算法这部分在第六章展开。 ### 5.4 xlsread 读取数值被转成浮点导致匹配失败 现象决策树所有分支都匹配不上投票全部走邻近值策略准确率掉到 70% 以下。 原因Excel 里的 1、2、3 读进 MATLAB 后是 double 类型但如果单元格格式异常比如部分数据被手工编辑过读取结果可能是 1.0000000001 这种带浮点误差的值。child_value 里存的是整数 1比较时 1.0000000001 不等于 1导致匹配失败。 解决源码里其实已经处理过一次——DValueroundn(DValue,-1) 把特征值四舍五入到一位小数。如果还是出现匹配问题把精度提到 roundn(DValue,0)直接取整到整数位。更稳妥的做法是在读取后统一执行 DValuedouble(int8(DValue))强制转成整数。 ### 5.5 树的数量对准确率的影响是单调的吗 现象把 tn 从 30 改成 100准确率反而下降了 0.5 个百分点。 原因随机森林的准确率随树数量增加通常是单调提升或持平的但不排除个别数据分布下的偶然波动。tn30 在这个数据集上已经达到收敛区间增量收益很小而每次运行的随机种子不同造成的波动可能大于 tn 增加带来的收益所以看起来「改坏了」。 解决判断 tn 的影响时固定随机种子rng(42) 放在主程序开头在同一条件下对比不同 tn 的准确率。同样地比较 sn 的影响也要固定随机种子否则两次运行之间的随机波动会盖过参数差异。 ### 5.6 主程序只跑一次但结果不可复现 现象同一个代码、同一个数据集两次运行准确率不同怀疑是「数据泄露」或者「程序不稳定」。 原因随机森林本身就是随机算法。每次训练时每棵树的样本和特征都是重新随机抽的两次运行的森林结构不同测试结果有小幅度差异是正常现象。 解决需要可复现实验时在 clear all 之后加一行 rng(2024) 固定随机种子。这样每次运行的随机序列完全一致结果可以复现。做课程设计答辩或者论文实验时建议把随机种子也写进报告说明实验的可复现条件。 ## 6. 二次开发思路把这套随机森林改造成自己的分类器 ### 6.1 把离散特征扩展成连续特征 这套系统最大的局限在第 1.7 节自己也承认了——只适用于离散属性遇到连续属性会严重过拟合。我在自己改造的时候第一步就是把分裂策略从 ID3 换成 C4.5 的思路在 avg_entropy 里对连续属性做二分切分先把该特征的所有取值排序然后逐个尝试相邻值的中点作为阈值选信息增益最大的阈值作为分裂点。 matlab % 连续特征二分切分的伪代码示意 values unique(DValue(:, featureIdx)); bestGain -inf; for k 1:length(values)-1 threshold (values(k) values(k1)) / 2; leftIdx DValue(:, featureIdx) threshold; rightIdx DValue(:, featureIdx) threshold; gain I - (sum(leftIdx)/n)*Entropy(leftIdx) ... - (sum(rightIdx)/n)*Entropy(rightIdx); if gain bestGain bestGain gain; bestThreshold threshold; end end连续特征和离散特征的分裂需要分别处理决策树节点的存储结构也要相应扩展——child_value里不再只存「取值」还要存「阈值」。这部分的改造量不算小但改完之后系统就能处理像年龄收入这类真实连续数值不再局限于人工编码的标签。6.2 把三分类输出改成二分类或多分类当前代码的TypeName硬编码了{1,2,3}如果业务只需要「同意 / 拒绝」二分类需要把训练集标签重新映射为 1/0 两类同时把CN_sta里的TypeName改成{1,0}并对应调整逻辑。改动点集中在find_most、statistics、vote三个函数主程序的训练循环不用动。如果你要处理 4 类以上TypeName需要重构成从数据里动态读取类别而不是写死在代码里。6.3 增加特征重要性输出原始描述里提到的「特征重要性评估 剔除」在代码里是缺失的。我建议在训练主循环里加一个统计数组每次TreeNode选出一个分裂特征时就把该特征编号对应的计数器加一。训练结束后用计数器值除以所有特征的总命中次数就得到特征重要性的近似估计。% 统计每个特征被选为分裂节点的次数 featureCount zeros(1, 6); % 在 TreeNode 选出 maxG(1) 作为分裂特征的位置累加 % 需要把 featureCount 作为全局或参数传入递归函数 featureCount(ClassPNum(maxG(1))) featureCount(ClassPNum(maxG(1))) 1; % 训练结束后归一化得到重要性 featureImportance featureCount / sum(featureCount); % 按重要性排序后即可做特征筛选这个统计量和随机森林的理论特征重要性是一致的——在分裂中被频繁选中的特征通常就是区分能力最强的特征。做完这一步就能根据重要性排序剔除靠后的特征比如把 6 个特征减到 4 个再跑一遍流程对比准确率就闭环了描述里的「特征提取过程」。6.4 移植到 Python 时的结构参考如果要把这套逻辑移植成 Python 脚本我一般会保留三层结构ID3Node递归建树 -RandomForest维护多棵树 -predict里做投票。核心的randperm(6,5)对应np.random.choice(6, 5, replaceFalse)randi([1,1500],1,sn)对应np.random.randint(1, 1501, sizesn)建树逻辑可以照搬递归框架只是把全局变量改成对象属性。没有 MATLAB 的xlsread和xlswrite就用pandas.read_excel和to_excel替代。移植过程中最容易被忽略的坑是数据类型的严谨性——MATLAB 的 cell 数组可以同时存数字和字符串Python 里要统一用numpy.ndarray或直接约定节点存储格式否则vote函数里做类型比较时很容易踩str和int比较的坑。从那以后我每次改造这套代码都会强制把「训练集、测试集、预测结果」的数据类型检查写进启动脚本里宁可多写两行断言也不让类型问题在递归函数里变成黑匣子。希望这份拆解能帮你把这套随机森林跑通、跑懂改造成自己真正能用的分类器。本文还有配套的精品资源点击获取