恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
随机森林MATLAB实现:从ID3决策树到投票机制全解析
首页
资讯中心
/
随机森林MATLAB实现:从ID3决策树到投票机制全解析
随机森林MATLAB实现:从ID3决策树到投票机制全解析
发布时间:2026/10/17 16:45:27
简介针对银行贷款审批中的分类预测问题这份资源完整呈现了基于随机森林算法的模式识别系统设计过程适合机器学习初学者、模式识别课程设计者及相关算法研发人员学习参考。文档以MATLAB R2019a为实验环境详细说明了随机森林算法思想、ID3决策树构建、特征重要性评估与筛选、有放回抽样、多树投票等核心环节并结合1500个训练样本与500个测试样本的公开数据集给出了从特征提取到准确率评估的完整实现思路。压缩包内包含1个doc文件容量为154KB文件内附有系统流程图、主要程序代码及注释包括randperm、randi、ID3、statistics等关键函数的用法便于读者对照理解或直接复现实验。该资源目前已有162人学习浏览对于想要快速掌握随机森林在模式识别中的落地步骤或需要完成课程设计、毕业设计的读者是一份结构清晰、可直接借鉴的参考材料。1. 随机森林为什么“不聪明的树”投票后反而最可靠单棵ID3决策树在贷款审批数据上未必比一个简单的规则引擎强多少——它贪婪、对噪声敏感、容易过拟合。但当你把30棵这样的树放在一起让它们对同一个贷款申请投票准确率却能冲到91.6%。这个反差本身就值得追问一群各自会犯错的弱分类器为什么聚合之后反而比其中任何一棵“聪明的树”都靠谱答案藏在随机森林的两重随机性里样本随机、特征随机。随机破坏了每棵树之间的相关性让它们“错得不一样”投票再把这些分散的错误相互抵消只留下一致的信号。这篇博文就从这套贷款审批系统的MATLAB源码出发拆开随机森林的底层机制——从bootstrap抽样、ID3分裂逻辑到三数组存储决策树、投票容错最后落到“为什么这些设计取舍能让458/500的准确率成立”这个具体结论上。2. 两重随机性bagging 和特征子集如何让30棵树互不雷同2.1 有放回抽样与45%的盲区900个样本从哪里来先看主程序里这行关键代码sn900; %随机可重复的抽取 sn 个样本 Sample_numrandi([1,1500],1,sn);%从 1 至 1500 内随机抽取 sn 个样本,1*sn 矩阵 SDataS(Sample_num,:); %一棵树的训练集randi([1,1500],1,900)是从1到1500的整数中有放回地随机取900个行号。注意“有放回”三个字——同一行数据可以被抽中多次也可能一次都不被抽中。每棵树都在一个与传统训练集不同的样本分布上学习。为什么一定要有放回如果不放回每棵树看到的都是同一个1500条数据的“完整面貌”只是顺序不同树的差异只来自特征随机集成效果会大打折扣。有放回抽样bootstrap aggregating即bagging制造了一个关键现象对任意一棵树大约有36.8%的原始样本从未出现在它的训练集中——这被称为“袋外数据”out-of-bagOOB。按这个概率推算每棵树实际只覆盖了约45%的原始样本剩下约55%对它而言是“盲区”。袋外数据有两个用途一是作为天然的验证集来估计泛化误差二是在特征重要性评估里充当“扰动检测器”。这套源码虽然没有显式计算OOB误差但在论文的特征选择流程描述中明确提到了“特征集对应的袋外误差率”——思路是一致的。这里有个细节值得停下来看为什么抽900而不是1500代码里sn900意味着每棵树的训练样本规模是原始数据的60%。这个比例让树之间的样本重叠度适中两棵树分别抽出来的900个样本期望上重叠比例在50%左右既保证了每棵树有足够数据拟合又让它们不至于长得一模一样。2.2 特征随机选5个m M 的真正作用再看另一行Featurerandperm(6,5);%随机选取数个特征randperm(6,5)从6个特征中随机选出5个作为这一棵树的“可用特征池”。在构建决策树时每个节点分裂只从这5个特征里挑信息增益最大的那个而不是从全部6个里选。这就是随机森林的“特征子空间随机化”常记作m M总特征数M6每次候选特征数m5。表面上看5 6不太符合常见经验法则比如Breiman建议m sqrt(M)但正因为M基数太小——只有6个特征——如果m取2或3每棵树在每个节点能看到的信息量就太少单棵树性能会大幅下降投票时“劣质树”太多反而拖累整体。m5是这个小特征基数下的工程折中具体取舍第6章会展开。这个随机性的核心价值在于去相关。ID3是确定性算法给定同样的训练数据和同样的特征集它每次都生成一模一样的树。如果30棵树用同样的样本、同样的特征训练投票就退化成“一个人投30票”毫无意义。随机特征让每棵树的“视野”不同树A可能没看到性别特征树B可能没看到收入特征它们在不同维度上做出判断误差模式才有差异。2.3 参数联动tn、sn、m 对模型的共同影响这套系统的设计参数有三个tn30树的数量、sn900每棵树的样本量、m5每节点候选特征数。它们不是孤立存在的参数代码取值偏大时的影响偏小时的影响tn30计算时间线性增长但误差持续缓慢下降方差大结果受少量树的偶然性影响sn900树之间样本重叠度升高多样性下降每棵树数据不足单棵树偏差变大m5树之间特征重叠多相关性上升接近bagging单棵树视野太窄信息增益选择空间小一个常见的误解是“树越多越好”。实际上tn从30增加到300准确率提升有限但训练时间成10倍增长。30在这个数据集上是一个性价比不错的点1500条样本、7个特征训练30棵ID3树在MATLAB里不过几秒而投票统计也已经足够平滑。3. ID3 决策树信息增益是怎么算出来、怎么被用来分裂的3.1 熵和信息增益分裂依据的推导随机森林的外壳是bagging内核是单棵决策树。这套系统选用的是ID3算法它的分裂准则是信息增益——先算当前样本集的熵再算按某个特征划分后的条件熵两者之差就是信息增益。% 计算样本分类的期望即信息熵 function IExp(CN,A) CNRowNumCN_sta( CN, A ); % 三类样本各自的数量 nlength(A); % 参与计算的样本数 I0; for i1:3 if CNRowNum(i)0 P(i)CNRowNum(i)/n; II-P(i)*log2( P(i) ); % 标准信息熵公式 end end endExp算的是当前节点样本集的熵三类贷款决策同意、考虑、不同意的分布越均匀熵越大越集中熵越小。avg_entropy则计算按某个特征划分后的加权条件熵——把样本按该特征的取值分组每组内部算熵再按组大小加权平均。function entropyavg_entropy( Attri, A, CN ) k0;entropy0; nlength(A); IExp( CN,A ); [PValue,RowNum]type_sta( Attri, A ); % 按属性值分组 for i1:length( PValue ) CIExp( CN, RowNum{i}); entropyentropy-length( RowNum{i} )/n*CI; end end信息增益就是Gain(i) I - Entropy(i)。ID3贪心地选择Gain最大的特征作为当前节点的分裂属性。逻辑很直白划分之后样本的纯度提升越多这个特征就越值得用来分裂。3.2 分裂时的“伪剔除”技巧把没用到的特征列置0这里有一个非常工程化的细节容易被忽略[Feature,~]sort(Feature); ClassPNumFeature; CLASSPNUM[1 2 3 4 5 6]; [CHA,~] setdiff(CLASSPNUM,ClassPNum) ; DValue(:,CHA)0;%把没用到的特征置 0Feature是这一棵树随机选中的5个特征编号setdiff找出没被选中的那个特征列然后把训练数据里这一列全部置为0。为什么用置0而不是真正删掉这一列因为ID3在处理时如果某一列的所有值都一样全是0那么按这一列划分时所有样本会落入同一组条件熵等于原熵信息增益恒为0。在Gain比较中这一列永远不可能胜出等于被“隐形屏蔽”了。这个技巧避免了动态裁剪矩阵的麻烦——矩阵始终是7列结构不变只是某些列在整个建树过程中永远当不上分裂属性。代价也很明显如果测试样本在某一列的值恰好是0比如“收入低”编码为0而这一列又被置0屏蔽了那么它不会产生任何区分作用。这是编码设计上的一个坑后面避坑章节会细说。3.3 递归终止的四个条件树为什么会停在这里TreeNode函数是建树的核心它的递归终止条件写得非常明确% 1、样本为空则树为空 if isempty(DValue) node{n1}[]; return; end % 2、用于划分的剩余属性为空选择多数元组所在的类作为结点 if isempty(ClassPNum) node{n1}find_most(CN,A); return; end % 3、样本中所有数据都属于同一类将此类作为结点 CNRowNumCN_sta(CN,A); if length(find(CNRowNum0))2 node{n1}CN(A(1)); return; end % 4、各属性的信息增益均小于 0选择多数元组所在的类作为结点 if max(Gain)0 node{n1}find_most(CN,A); return; end四个条件的语义分别是没有样本了没有特征可分了样本已经纯了再分也不会更纯了增益为0或负。第4个条件很关键——现实数据里噪声会让信息增益出现负值如果不停下来树会为了拟合个别噪声样本无限分裂下去这就是过拟合的根源。加上这个条件树会在“再分无益”时果断收手用一个多数类标签收尾。4. 三数组存储node、child_value、child_node_num 如何还原一棵树4.1 存储协议内部节点和叶子节点怎么区分这棵树的存储方式很特别——不是常见的左子右兄弟指针而是三个平行的cell数组rnodecell(3,1); % 3*1 的单元数组 rchild_valuecell(3,1); % 3*1 的单元数组 rchild_node_numcell(3,1);% 3*1 的单元数组rnode存放节点内容child_value存放每个节点各分支对应的特征值child_node_num存放每个节点的子节点编号。三个数组通过数组下标彼此关联构成一棵完整树。具体来说每个节点n有三种可能内部节点rnode{n}存放一个特征编号1到6表示“这个节点按第几列特征分裂”rchild_value{n}存放该特征的所有取值rchild_node_num{n}存放每个取值对应的子节点编号。叶子节点rnode{n}存放一个类别字符串1、2或3后两个数组为空。空节点三个数组全空。判定一个节点是叶子还是内部节点不需要额外的类型字段——只要看child_node_num{n}是否为空即可。这个设计在MATLAB的cell数组生态里非常自然既省了结构体字段又让遍历逻辑变得极简。4.2 行号路由A数组如何代替数据切片传统决策树实现会把每个子节点的训练数据复制一份传给递归函数但这套代码没有用这种空间换时间的做法。它只传一个行号数组Afunction [node,child_value,child_node_num]TreeNode( DValue, CN, A, ClassPNum,m)A表示“当前节点涉及的样本在原始DValue中的行号”。每次分裂时type_sta函数按属性值把A分成多个子集每个子集对应一个分支再递归传入[PValue,RowNum]type_sta( DValue(:,ClassPNum(maxG(1))), A ); ... for i1:length(PValue) [node,child_value,child_node_num]TreeNode( DValue, CN, RowNum{i}, ClassPNum,n1 ); endRowNum{i}是一个行号集合表示该分支下包含哪些样本。整个建树过程自始至终只有一份DValue矩阵在内存里递归传递的只是越来越小的行号集合。对于1500条数据来说内存无所谓但这个“行号路由”的模式一旦理解迁移到百万级数据时思路也是通的——每次分裂只需要一个索引数组不需要反复切片拷贝。还有一个细节节点创建时会主动向父节点登记自己nlength(node); if m0 klength(child_node_num{m}); child_node_num{m}(k1)n1; end因为MATLAB的cell数组下标从1开始n1是当前要创建的新节点的编号。这段代码把新节点编号追加到父节点的child_node_num{m}里对应着该分支的走向。这是“先建节点、再补关系”的典型做法逻辑顺序是先有子节点才有父子关系的记录。4.3 遍历一维化测试时从根走到叶的路径树建好之后测试样本的预测就是一次从根到叶的路径查找。看vote函数的核心循环function [type] vote(rnode,rchild_value,rchild_node_num,PValue,j) n1; %从树的根结点即 node{1}开始查找 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); k0; break; end end ... end typernode{j,1}{n}; endPValue是传入的测试样本向量rnode{j,1}{n}是当前节点的特征编号。PValue(特征编号)取出测试样本在该特征上的取值然后遍历child_value找匹配的分支值找到就把n更新为对应子节点编号进入下一层循环。直到某节点的child_node_num为空——它是叶子——循环退出rnode{j,1}{n}里的类别就是这棵树的投票结果。这个遍历过程本质是“一棵决策树的if-else链”的矩阵表达。每个节点在问一个问题测试样本在这个特征上取什么值根据答案决定往哪走。如果测试样本的特征值恰好不在当前节点的分支值集合里代码还留了一个容错分支这个机制在第5章细说。5. 投票机制与常见问题排查91.6%背后有哪些坑5.1 多数投票与特征值抖动容错测试阶段的核心是statistics函数function [type] statistics(tn,rnode,rchild_value,rchild_node_num,PValue) TypeName{1,2,3}; TypeNum[0 0 0]; for i1:tn %对测试向量进行投票共有 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)}); end30棵树各自投票三个类别的票数统计在TypeNum里票数最多的类别胜出。注释里那句“统计函数对输入的测试向量进行投票然后统计出选票最高的标签类型输出”说得已经很清楚。这里有个细节maxnfind(TypeNummax(TypeNum))返回的是所有并列最大值的下标代码取第一个作为最终结果。并列情况在30棵树里不算罕见取第一个意味着“谁先达到最高票谁赢”这在工程上是可接受的但你心里得有数——并列时结果有一定任意性。vote函数里还有一个隐蔽的容错逻辑if ilength(rchild_value{j,1}{n}) % 若这个值在分类器中不存在则取其最近的值进行分类 PValue(rnode{j,1}{n})PValue(rnode{j,1}{n})0.1*k; PValueroundn(PValue,-1); end k(-1)^k*( abs(k)1 );如果测试样本的特征值在当前节点找不到匹配分支代码会给它加一个逐步放大的偏移量0.1、0.1、-0.2、0.3、-0.4……依次试探附近的值直到匹配上某个分支或走到叶子。这个机制的本质是“最近邻容错”——容忍训练数据和测试数据之间的小幅浮点偏差。它的代价是试探到的可能不是正确分支树会沿着一条错误的路径走下去。对于离散标签数据这个机制基本是无害的但如果你拿它去处理连续值数据这个抖动逻辑会让树在相近但不相等的取值之间反复横跳产生难以排查的诡异结果。5.2 坑1连续属性会让树过拟合到“找不出规律”论文1.7里明确写了一句这套实现只适用于属性值是标签值离散值的类型如果训练集中含有连续属性样本训练出的决策树将存在较严重的过拟合现象。现象把年龄、收入等连续数值直接丢进去训练树变得异常深测试准确率反而下降。原因ID3的分裂条件是严格相等比较。连续特征的取值基本没有重复每一条样本都可能成为独立分支树会一路分裂下去直到每个叶子只剩一条样本——这是教科书级的过拟合。代码里虽然做了roundn(DValue,-1)把值保留到一位小数但这只是压低了采样精度没有改变“等值匹配”的分裂机制。解决要么把连续特征预先分箱离散化比如把收入切成低/中/高三档要么换用支持阈值比较的C4.5算法或CART的基尼指数。随机森林本身可以处理连续特征但前提是决策树实现支持二分阈值分裂。5.3 坑2类别不均衡让91.6%失真现象500个测试样本458个分类正确准确率91.6%。看起来很漂亮但先别急着下结论。原因如果测试集里“同意贷款”占了多数而模型恰好对“同意”的判定特别准整体准确率就会被这个大头拉高掩盖了对“还需考虑”和“不同意”两个类别的低识别率。更极端的情况是如果一类占80%一个“永远预测多数类”的模型也能有80%准确率看起来比随机强很多但业务上一个“该拒绝却放行”的错误代价远高于“该放行却拒绝”。解决跑完准确率之后一定要看混淆矩阵——三类的召回率和精确率分别多少。在你自己的复现里可以加一行confusionmat(gd, type)把三个类别的正确数打印出来。如果发现某一类的召回率低于60%这个模型在业务上其实不可用91.6%只是错觉。5.4 坑3m太大导致森林退化成bagging现象无论怎么调tn准确率都上不去接近某个瓶颈就停滞。原因m5意味着每棵树几乎共享同一个特征池——6个特征里每次去掉1个所有树看到的特征高度重叠。如果数据里某个特征比如信用等级对贷款决策起决定性作用那么几乎每棵树的每个节点都会优先选它30棵树长成“同一棵树”随机森林退化为bagging或退化为“单棵大树”。解决在小特征集上可以自己做一个m的网格搜索——分别试m2,3,4,5用OOB误差或交叉验证比较。你会观察到m越小单棵树越弱但树间差异越大m越大单棵树越强但树间越相似。最优值通常不在两端而是中间的某个点。5.5 坑4不固定随机种子复现靠运气现象同一份数据今天跑一次准确率91.6%明天再跑一次变成了90.2%或92.4%结果上下浮动。原因randperm和randi都依赖MATLAB的全局随机数生成器。脚本开头没有rng(seed)每次运行抽取的样本集和特征集都不同随机森林的“随机”变成了不可控的“飘”。这不算bug但对于实验复现和调参来说很致命——你没法判断准确率的提升来自参数调整还是随机波动。解决在脚本开头加一行rng(42)或任意固定整数保证每次运行用相同的随机序列。这样同一份数据、同一组参数跑出来的结果完全一致调参才有可比性。提示固定随机种子不等于“消除随机性”它只是让随机过程可复现。森林内部依旧是随机的只是随机序列有序了。6. 设计取舍特征选择与 m 的取值哲学6.1 为什么用 OOB 误差而不是测试集误差来选特征论文1.4描述的特征选择流程是计算特征重要性、按降序排序、剔除一定比例特征、重复迭代、最后选出袋外误差率最低的特征集。这个流程的核心评判标准是OOB误差而不是测试集准确率。这两个指标的差别是数据流向的问题。测试集只能碰一次——你用测试集调了一次特征集再拿测试集报告准确率就已经发生了数据泄露得出的91.6%不再可信。而OOB误差来自训练过程本身每棵树大约有36.8%的样本没参与训练这些“袋外”样本天然就是验证集用它们来评判特征子集的效果不需要动测试集一根手指头。特征重要性的常见实现思路是“置换检验”把某一列特征的值随机打乱观察OOB误差上升多少上升越多说明这个特征越重要。误差率最低的特征集被选中本质上是在“特征数量”和“特征质量”之间找一个平衡点——特征太少丢失信息特征太多引入噪声和计算开销。6.2 m5 的取舍逻辑为什么敢偏离 sqrt(M) 经验法则Breiman的原始论文建议m取sqrt(M)或log2(M)1但这个系统是6个特征取5个明显偏离经典法则。怎么理解这个偏差核心原因是M的绝对值太小。sqrt(6)≈2.45如果取2或3每个节点只从2~3个特征里挑分裂属性那么关键特征比如信用等级被漏掉的概率太高单棵树的偏差会急剧增大。偏差这东西投票是消不掉的——投票只能降方差不能降偏差。反过来m5时每个节点基本都能看到所有关键特征单棵树质量有保证代价是树之间相关性升高部分抵消了投票的降方差作用。在6个特征的场景里“单棵树的质量”比“树之间的差异”更稀缺所以m取5是有道理的。如果M变成60或600m5这种固定值就完全没戏——方差会吃掉一切。这是我一般会这么理解这个设计的地方m的取值本质上是在偏差和相关性之间做权衡经验法则只适用于特定条件跑数据之前先手算一遍特征基数比死记公式靠谱得多。6.3 从一次翻车到固定复查有一次我在另一个数据集上照搬了这套代码框架数据是连续型特征我只改了输入文件满心以为随机森林能“自动处理”一切结果OOB误差高达37%。排查了半天才发现ID3的等值分裂根本不认连续值树长大得失控整片森林都在过拟合的泥潭里挣扎。从那以后我每次跑随机森林都会强制走一遍三件套固定rng种子检查特征类型是否离散连续值就分箱或换C4.5打印混淆矩阵而不是只看准确率。这套贷款审批系统的源码把随机森林的骨架搭得很清楚但真正能让你在真实数据上不翻车的是理解它每一步为什么要这么设计——了解了机制才能在对的场景里用对参数。希望帮到你。本文还有配套的精品资源点击获取