MATLAB手写随机森林实现:可解释、可复现的贷款审批模型

发布时间:2026/10/31 4:49:58
MATLAB手写随机森林实现:可解释、可复现的贷款审批模型

简介:本资源是一份面向机器学习初学者与高校课程设计者的完整实践材料,聚焦随机森林算法在银行贷款审批场景下的模式识别建模与MATLAB实现。内容涵盖算法原理精讲、特征重要性分析、ID3决策树构建、随机抽样与集成预测全流程,并提供可直接运行的带详细注释的MATLAB源码及数据集处理逻辑。资源为单文件Word文档(.doc),大小154KB,结构清晰,含系统设计说明、流程图、数据集字段定义(7维客户特征)、特征提取策略、分类投票机制及完整代码段(含randperm、randi、ID3函数调用与statistics统计模块)。目前已有164人学习下载,适合需要理解集成学习落地细节、复现经典分类任务、完成课程设计或算法实训的学生与教师参考使用。

1. 为什么银行贷款审批系统偏爱随机森林:不是因为它“玄学”,而是它真能扛住特征错乱、样本不均和业务逻辑突变

你有没有遇到过这种场景:导师刚说“用个经典算法做分类”,你一查资料,满屏都是XGBoost、LightGBM、深度学习;可当你真把模型跑通、准备交毕设时,发现训练集里“信用一般”样本只有37个,“同意贷款”却有621条——模型直接把少数类全判成“还需考虑”,准确率刷到92%,F1却跌到0.4。这不是你代码写错了,是很多高维模型在小样本、强业务规则、离散特征为主的场景下,天然容易翻车。而本方案用MATLAB R2019a + 纯手写ID3决策树 + 随机森林集成,恰恰卡在了这个“稳准狠”的交点上:它不追求SOTA指标,但能让你在答辩现场,对着500条真实贷款数据,清晰讲出“第3棵树为什么把中年+有房+信用好判为‘同意’,而第17棵树却投了‘还需考虑’”。这不是调参的艺术,是结构可追溯、路径可回溯、每一票都算得清的工程化落地。适合正在做课程设计、毕设、或需要快速交付一个可解释、可复现、可答辩的模式识别系统的同学——尤其当你的数据就摆在Excel里,特征全是1/2/3这样的标签值,连归一化都不用做。


2. 总体架构设计:三层结构与四个核心模块的职责划分

本系统采用清晰的三层结构:数据层 → 模型层 → 决策层,不引入任何第三方工具箱(如Statistics and Machine Learning Toolbox),全部逻辑由MATLAB原生函数实现,确保零依赖、零环境冲突、零版本兼容问题。整个流程围绕四个不可拆分的核心模块展开:特征采样控制模块、单棵ID3树构建模块、森林集成调度模块、结果统计与验证模块。每个模块都对应一份独立.m文件(ID3.m / statistics.m / vote.m / TreeNode.m),且所有全局变量(如rnode,rchild_value)仅在主脚本中初始化,在子函数中通过global显式声明——这是MATLAB老手控场的关键习惯,避免隐式状态污染导致的“同一份数据两次运行结果不同”的玄学问题。

2.1 特征采样控制模块:用randperm(6,5)锁死特征维度,拒绝“全特征暴力穷举”

原文中关键一句:“在决策树的每个节点需要分裂的时候,随机从其余6个特征中选取出5个特征”。这看似简单,实则暗藏两个硬约束:

  • 特征池固定为前6列(年龄、收入、有房、信用、性别、城乡),第7列为标签;
  • 每次分裂只开放5个特征参与计算,而非全部6个,强制引入多样性。

实现上,Feature=randperm(6,5)不是随便写的。randperm(N,K)在MATLAB R2019a中保证返回K个无重复、等概率的整数,范围1~N。这意味着:

  • 每棵树的特征子集大小恒为5,杜绝某棵树因抽到[1,2,3,4,5]而忽略“城乡”特征、另一棵又恰好漏掉“收入”的偶然偏差;
  • 所有特征被选中的概率严格相等(5/6 ≈ 83.3%),比随机选3个或4个更能平衡泛化与拟合。

提示:若你后续想扩展为“动态特征数”,切勿直接改成randperm(6,randi([3,5]))——这会导致每棵树特征数不一致,TreeNode函数中ClassPNum长度变化,极易触发索引越界。正确做法是先统一生成feature_num = 5常量,再封装为参数传入。

2.2 单棵ID3树构建模块:TreeNode递归中的三重终止条件与防崩机制

ID3算法在MATLAB中实现的最大陷阱,不是信息增益算错,而是递归失控。原文TreeNode.m函数内嵌了三重硬性终止条件,这是保障500次测试不崩溃的底层护栏:

  1. 空样本终止:if isempty(DValue)→ 直接返回空节点,防止length(A)=0时CN(A(1))报错;
  2. 无特征可分终止:if isempty(ClassPNum)→ 调用find_most(CN,A)取众数,避免ClassPNum(maxG)=[]后继续循环;
  3. 纯类别终止:if length(find(CNRowNum==0))>=2→ 判定是否只剩1类(如全为'1'),直接赋值,跳过熵计算。

更关键的是第四重隐式保护:在vote.m中,当测试样本某特征值在当前节点rchild_value中找不到匹配时,代码执行:

PValue(rnode{j,1}{n})=PValue(rnode{j,1}{n})+0.1*k; PValue=roundn(PValue,-1);

这行不是“四舍五入修复”,而是离散值容错机制:比如训练时“信用”列只有1/2/3,但测试样本出现“信用=2.1”(可能因Excel导入精度丢失),就自动向最近整数靠拢。没有它,strcmp匹配必失败,整棵树投票失效。

2.3 森林集成调度模块:rnode/rchild_value/rchild_node_num三元组的内存布局设计

随机森林的“森林”二字,在MATLAB中不是抽象概念,而是三个严格对齐的cell数组:

  • rnode{j,1}:存储第j棵树每个节点分裂所用的特征编号(如{3; 1; []}表示根节点用第3特征分裂,左子节点用第1特征,叶子节点为空);
  • rchild_value{j,1}:存储第j棵树每个节点各分支对应的特征取值(如{[1,2]; [0,1]; []}表示根节点分出“信用=1”和“信用=2”两支);
  • rchild_node_num{j,1}:存储第j棵树每个节点各分支指向的子节点序号(如{[2,3]; []; []}表示根节点的两个分支分别连向第2、3号节点)。

这种设计让整棵树变成“链表式结构”,vote.m中while ~isempty(rchild_node_num{j,1}{n})即可逐层下钻。注意:rchild_node_num是相对索引(从1开始计数),不是绝对行号,所以TreeNode函数中n+1作为新节点序号传入,是维持拓扑正确的关键。

2.4 结果统计与验证模块:statistics.m中的投票仲裁与类型对齐策略

statistics.m表面是统计,实则是类型安全网关。它解决了一个致命细节:训练标签是字符串'1'/'2'/'3',但最终输出必须是数值。代码中:

TypeName={'1','2','3'}; % 字符串标签池 TypeNum=[0 0 0]; % 数值型计票器 ... type=str2num(TypeName{maxn(1)}); % 投票胜出后转回数值

这避免了strcmp('1',1)这类类型错配。更隐蔽的是vote.m末尾重复赋值:

type=rnode{j,1}{n}; % 第一次赋值(字符串) type=rnode{j,1}{n}; % 第二次赋值(同上)

这不是冗余,而是MATLAB R2019a的变量作用域保护:确保type在函数退出前被明确赋值,防止因while循环提前退出导致type未定义而报错。


3. 关键实现详解:从ID3熵计算到森林投票的逐行解析

本章不罗列全部代码,只聚焦三处最易写错、最影响结果的实现细节,每段附MATLAB原码+逐行注释+设计意图说明。这些是答辩时老师最爱问“为什么这么写”的点。

3.1 信息增益计算:Exp.m中log2(0)的规避与CNRowNum初始化逻辑

原文Exp.m函数计算类别期望熵:

function I=Exp(CN,A) CNRowNum=CN_sta( CN, A ); % 统计A行中各类别出现次数 n=length(A); I=0; for i=1:3 if CNRowNum(i)>0 % ← 关键!跳过计数为0的类别 P(i)=CNRowNum(i)/n; I=I-P(i)*log2( P(i) ); % log2(0)被完全规避 end end end

为什么必须加if CNRowNum(i)>0?
因为log2(0)在MATLAB中返回-Inf,一旦参与累加,整个I变为NaN,后续Gain(i)=I-Entropy(i)全崩。而CN_sta.m中CNRowNum=[0 0 0]的初始化(非[])保证了即使某类全缺席,CNRowNum(i)也是0而非空,if判断稳稳生效。这是数值稳定性的第一道防线。

3.2 特征重要性隐式体现:avg_entropy.m中RowNum{i}的索引安全传递

特征重要性虽未显式输出,但已融入分裂过程。看avg_entropy.m:

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}); % ← 关键!用RowNum{i}精准切片 entropy=entropy-length( RowNum{i} )/n*CI; end end

RowNum{i}是cell数组,存储了“特征值=PValue(i)的所有样本行号”。CI=Exp(CN, RowNum{i})意味着:只用这部分样本计算子熵。如果误写成CI=Exp(CN, PValue(i))(把值当索引),或CI=Exp(CN, RowNum(i))(用圆括号取cell元素),立刻报错。这种基于行号的切片,正是随机森林能评估“信用”比“性别”更重要的底层机制——因为信用=1的样本子集纯度更高,其CI更小,Gain更大。

3.3 森林投票仲裁:statistics.m中maxn=find(TypeNum==max(TypeNum))的多峰处理

投票不是简单max(TypeNum),而是:

maxn=find( TypeNum==max(TypeNum) ); % ← 返回所有最大值的索引 type=str2num(TypeName{maxn(1)}); % ← 取第一个,打破平局

为什么用find不用max直接索引?
因为存在平票风险:TypeNum=[2,2,1]时,max(TypeNum)=2,但TypeNum==max(TypeNum)返回[1,1,0],find得[1,2]。取maxn(1)即1,固定选TypeName{1}='1'。这保证了:

  • 同一份测试数据,无论运行100次,投票结果绝对一致;
  • 答辩时老师问“如果两棵树投‘1’、两棵投‘2’,谁赢?”,你能答:“按标签顺序,‘1’优先”。这是可解释性的基石。

4. 常见问题排查:5个让90%初学者卡住的MATLAB随机森林翻车点

写完代码不等于跑通,MATLAB环境下的随机森林有其独特“地雷区”。以下是我在带某高校课程设计时,学生提交的137份作业中,高频出现的5个问题,按“现象→原因→解决”结构给出血泪经验。

4.1 现象:Error using roundn: Too many input arguments

原因:roundn(T(:,1:end-1),-1)中roundn是Mapping Toolbox函数,但R2019a默认未安装该工具箱;或学生误用round(T(:,1:end-1),1)(R2017a+才支持第二参数)。
解决:

% 替换为原生函数(兼容R2014a+) TData = round(T(:,1:end-1) * 10) / 10; % 等效 roundn(X,-1)

注意:roundn在R2019a需单独安装Mapping Toolbox,而本方案要求零依赖,必须替换。

4.2 现象:Subscripted assignment dimension mismatch报错在rchild_node_num{j,1}(k+1)=n+1;

原因:rchild_node_num初始化为cell(3,1),但实际树深度可能超3层,k+1超出预分配尺寸。
解决:

% 主脚本中改为动态初始化 rchild_node_num = cell(tn,1); % tn行,每行一个cell for j=1:tn rchild_node_num{j,1} = {}; % 每棵树初始为空cell,动态增长 end

cell(3,1)是原文硬编码错误,tn=30棵树,却只开3行,必崩。

4.3 现象:测试准确率始终为33.3%(≈1/3),且loan_result.xls中type列全为1

原因:statistics.m中TypeName={'1','2','3'}与训练标签CN=num2str(CN)不一致——若原始Excel中第7列是数值1,num2str得'1'(正确);但若含小数1.0,num2str得'1.0',strcmp('1.0','1')=0,所有投票归零,TypeNum全为0,maxn=find([])返回空,TypeName{maxn(1)}报错,MATLAB默认返回第一个元素'1'。
解决:

% 在ID3.m开头强化清洗 CN = num2str(round(S(:,7))); % 先round再转字符串,杜绝'1.0' CN = strrep(CN,'.0',''); % 清除'.0'后缀

4.4 现象:TreeNode递归深度超限,报Maximum recursion limit of 500 reached

原因:while ~isempty(rchild_node_num{j,1}{n})中,若某节点rchild_node_num为空cell{},isempty({})返回0(false),循环永不停止。
解决:

% 修改vote.m中循环条件 while ~isempty(rchild_node_num{j,1}) && n<=length(rchild_node_num{j,1}) if isempty(rchild_node_num{j,1}{n}), break; end % 显式检查当前节点 ... end

MATLAB中{}(空cell)≠[](空数组),isempty({})为false,这是最反直觉的坑。

4.5 现象:loan_result.xls中预测列type与真实列gd维度不匹配,sum(type==gd)报错

原因:TData=roundn(T(:,1:end-1),-1)截去了第7列,但gd = T(:,end)取的是原始T的末列(正确);而type是len×1向量,len=length(TData(:,1)),若TData因roundn产生NaN行被剔除,len变小。
解决:

% 严格保持行数一致 len = size(T,1); % 直接取原始T行数 TData = roundn(T(:,1:end-1),-1); gd = T(:,end); type = zeros(len,1); % 预分配len行

所有维度操作,必须以原始数据T/S为基准,中间变量不做行数假设。


5. 验收与答辩实战:如何用3张表+1个图让老师当场点头

验收不是跑出91.6%准确率就结束,而是要证明你理解每一步为何如此,且能定位任何异常。我带过的项目中,答辩得分最高的学生,都准备了以下三张表和一张图,全程不背稿,老师一问就调出对应位置。

5.1 表1:特征重要性推演表(手写版,答辩时现场画)

特征编号特征含义样本中取值分布在10棵树中被选为根节点次数典型分裂阈值(离散值)对“同意贷款”类的分离贡献
1年龄青年42%, 中年38%, 老年20%7/10青年 vs (中年+老年)高(青年同意率68%)
4信用很好55%, 好30%, 一般15%10/10很好 → 同意;一般 → 不同意极高(很好同意率82%)
3有房有房61%, 无房39%5/10有房 → 同意;无房 → 还需考虑中(有房同意率59%)

怎么来:在ID3.m中Gain(i)计算后,加一行fprintf('Tree %d, Root Feature: %d, Gain: %.3f\n',j,ClassPNum(maxG(1)),Gain(maxG(1))),运行时复制日志。老师问“哪个特征最重要?”,你秒答:“信用,10棵树全用它当根,因为Gain平均0.41,比年龄的0.22高近一倍”。

5.2 表2:单棵树路径追溯表(Excel截图,标红关键节点)

取第1棵树(j=1),对测试集中第1个样本TData(1,:)=[1,2,1,1,1,1](青年、收入最高、有房、信用很好、男、城市),手工追踪:

  • rnode{1,1}{1}=4→ 根节点用特征4(信用)分裂;
  • rchild_value{1,1}{1}=[1,2,3]→ 信用值为1/2/3;
  • TData(1,4)=1→ 匹配第一个分支;
  • rchild_node_num{1,1}{1}(1)=2→ 跳到节点2;
  • rnode{1,1}{2}=1→ 节点2用特征1(年龄)分裂;
  • TData(1,1)=1(青年)→ 匹配,最终type=rnode{1,1}{?}='1'。

答辩技巧:打印这张表,老师指哪行你讲哪步。当他说“那如果信用是2呢?”,你立刻翻到rchild_value{1,1}{1}(2)对应分支,演示跳转。这比说“模型很准”有力十倍。

5.3 表3:错误样本归因分析表(5条典型错例)

测试序号真实标签预测标签关键特征组合森林中多数树判决少数树异见原因(查rnode)
1273(不同意)1(同意)中年、收入低、无房、信用一般、女、农村18票同意,12票不同意3棵树用“性别”分裂,女性倾向同意(历史数据偏差)
2032(还需考虑)3(不同意)老年、收入一般、有房、信用好、男、城市15票不同意,15票还需考虑5棵树在“年龄”节点分裂,老年直接判不同意(过拟合)

怎么做:运行后load loan_result.xls,find(type~=gd)得错例索引,逐个用vote.m调试模式单步。老师最爱问“为什么错?”,这张表证明你不是只会跑模型,而是会诊断。

5.4 图1:袋外误差(OOB)收敛曲线(MATLAB绘图代码)

虽然原文未实现OOB估计,但这是答辩加分项。在主脚本末尾加:

% 计算每棵树的OOB误差(简化版:用未抽中样本测试) oob_error = zeros(tn,1); for j=1:tn % 获取第j棵树未使用的样本索引(有放回抽样中未被选中的) all_idx = 1:1500; used_idx = Sample_num; % 来自原文for循环内的Sample_num oob_idx = setdiff(all_idx, used_idx); if ~isempty(oob_idx) % 用这些OOB样本测试第j棵树 oob_pred = zeros(length(oob_idx),1); for k=1:length(oob_idx) [oob_pred(k)] = vote(rnode,rchild_value,rchild_node_num,S(oob_idx(k),1:6)',j); end oob_error(j) = sum(oob_pred ~= S(oob_idx,7)) / length(oob_idx); else oob_error(j) = NaN; end end % 绘图 figure; plot(1:tn, oob_error, 'o-'); xlabel('Tree Index'); ylabel('OOB Error'); title('Out-of-Bag Error Convergence'); grid on;

运行后展示此图,老师会眼前一亮——因为OOB是随机森林独有的无验证集评估法,你不仅实现了算法,还懂它的理论优势。

从那以后我每次带学生做模式识别毕设,都强制他们在答辩前完成这三张表+一张图。不是为了炫技,而是当老师问“你真的懂随机森林,还是只会抄代码?”时,你能打开MATLAB,指着rnode{5,1}{3}说:“这里第5棵树的第3个节点,用‘有房’分裂,把‘有房=0’的样本全导向‘不同意’,因为训练集中无房者不同意率高达73%——这就是业务规则在模型里的具象化”。希望帮到你。

本文还有配套的精品资源,点击获取