简介:这份资源是一份以随机森林算法为核心的完整模式识别系统设计与实现文档,面向机器学习初学者、模式识别课程设计者及MATLAB算法实现人员。系统以银行贷款审批为典型应用场景,基于MATLAB R2019a实现,涵盖随机森林思想解读、1500个训练样本与500个测试样本的数据集分析、7维特征的重要性评估与提取方案,以及基于ID3算法构建多棵决策树并投票集成的完整分类流程。文档同时给出系统设计说明、流程图、特征选择与分类过程的详细步骤,并附有含注释的主要程序代码,包括随机抽样、特征随机选取、决策树训练与分类结果统计等关键实现,读者可直接参考复现,也可迁移至信用评估、客户分类等相似任务。资源包内共1个doc文件,整体仅154KB,内容集中、便于阅读和打印。目前已有162人学习下载,适合需要快速掌握随机森林工程化实现思路并产出课程报告或毕设方案的学习者使用。
1. 随机森林与银行贷款场景:从 1500 条样本到 91.6% 准确率
这套《基于随机森林算法的模式识别系统设计》不是拿来跑数就完事的作业包。它把一个真实业务问题——银行要不要批准这笔贷款——压缩成 1500 条训练样本和 500 条测试样本。每一条记录有 6 个离散特征(年龄、收入、房产、信用、性别、居住地),第 7 列是贷款结论:1 同意、2 再想、3 拒绝。你在 MATLAB R2019a 里跑完,会得到一份 loan_result.xls,以及一个精确到小数的分类准确率:91.6%。
工程的看点在于:它没有调用现成的随机森林库,而是自己实现了随机森林分类器。核心是三十棵 ID3 决策树,每棵树随机抽 900 个样本、随机选 5 个特征来分裂,最后用投票决定类别。看懂这套代码,你对“自助采样”、“特征随机子空间”、“信息增益”这些词的理解,会比看十遍概念清晰得多。
适合三类人:正在做模式识别课程设计、需要 MATLAB 源码的开发者;被黑匣子困住、想弄清楚随机森林内部怎么存树的人;以及想把这套逻辑移植到金融审批、信用评估场景的初级工程师。
2. 数据准备与特征工程:把 6 个离散特征喂给 ID3 前的三个必要动作
绝大多数人拿这份代码第一步不是看算法,而是直接跑。结果会发现 loan_train.xls 读进来一堆 NaN,或者 ID3 递归到一半报“索引超出范围”。原因很简单:这份工程的数据是离散标签,而且 Excel 表格很可能带着表头。所以在做随机森林之前,必须先把数据清洗成“只有数值、没有空值、没有文本”的矩阵。
2.1 数据字典:7 列里只有前 6 列是特征
原始数据来自loan_train.xls和loan_test.xls,工程文档里给出了一套固定编码。整理成表就是下面这样。
| 列 | 字段 | 取值编码 |
|---|---|---|
| 1 | 年龄 | 1 青年,2 中年,3 老年 |
| 2 | 收入 | 0 低,1 一般,2 最高 |
| 3 | 有房 | 0 没房,1 有房 |
| 4 | 信用 | 1 很好,2 好,3 一般 |
| 5 | 性别 | 0 女,1 男 |
| 6 | 居住地 | 0 农村,1 城市 |
| 7 | 贷款结果 | 1 同意,2 还需考虑,3 不同意 |
这里注意一个常见误用:第 2 列收入的编码是 0、1、2,但 1 代表“一般”,2 代表“最高”,0 代表“低”。数值大小并不反映收入高低,它是名义变量。ID3 在分裂时只判断“等于哪个值”,不会比较大小,所以这样的编码不会影响树结构。但如果你在预处理时顺手做了归一化或者按大小排序,等于把业务含义塞进了算法里,树会变得很怪。
还有一个更隐蔽的坑:训练集和测试集的编码必须完全一致。比如某份测试 Excel 里把“同意贷款”写成 1,“还需考虑”写成 2,“不同意”写成 3,但另一份把顺序换了,投票结果就会乱掉。拿到数据第一件事不是跑模型,而是对每一列跑一次unique,确认训练集和测试集的取值集合一样。
2.2 xlsread 读入与表头处理
原工程里直接写了S=xlsread('loan_train.xls')。这个函数在遇到表格首行是文字表头时,会把第一行读成 NaN。后面randi抽样时如果抽到这个 NaN 行,ID3 计算熵就会出错。我在复现时习惯先包一层读取函数:
function [data] = loadLoanSheet(filename) raw = xlsread(filename); % 如果第一行有表头,xlsread 会把它变成 NaN if any(isnan(raw(1, :))) raw(1, :) = []; end % 保留一位小数,过滤掉 Excel 浮点尾差 data = roundn(raw, -1); end这里roundn(raw, -1)的作用是四舍五入保留一位小数。对 1、2、3 这种整数没有影响,但能避免从 Excel 读出的 1.000000000000001 这种噪声。测试集和训练集最好都走同一个函数,否则两边数据精度不一致,投票时会出现“明明取值相同,却匹配不上”的诡异情况。
另外,xlsread对纯数值表格返回 double 矩阵;如果贷款标签那一列原来是“同意 / 考虑 / 拒绝”这样的中文文本,函数返回的就不是完整矩阵,特征会错位。常见做法是在 Excel 里加辅助列,把文本预先映射成 1、2、3,再交给xlsread。原工程里的数据已经是数字,不需要这一步,但你自己换数据集时很容易踩中。
2.3 固定随机种子,否则复现不出 91.6%
原工程的主循环里直接调用randperm和randi,没有设置随机种子。这意味着每次运行,30 棵树的样本组合、特征组合都不一样,最终准确率会有 2 到 3 个百分点的波动。91.6% 只是某一次运行的结果。
要稳定复现,在训练前加一行:
rng(2024); % 固定随机数种子,保证每棵树可复现固定种子不是为了“作弊”,而是为了让后续调参实验可对比。你改了一个参数,如果两次结果不同,可能只是抽样波动,而不是参数真的变好了。工程文档里提到的“特征重要性剔除”流程,实际代码是用randperm(6,5)随机选择特征来实现的,并没有真正计算特征重要性排序。这种情况下随机种子对结果的影响更大,所以开始动手前务必先固定它。
3. 训练主流程:ID3 手写决策树与森林存储结构
随机森林的“随机”体现在两个地方:样本有放回抽样、特征随机抽选。原工程把这两步集中在主循环里,然后调用 ID3 生成单棵树。真正看懂这一章,你就明白为什么 91.6% 不是靠一个模型,而是靠 30 个“略微不同的弱分类器”投票出来的。
3.1 主循环:每棵树只用了 900 个样本和 5 个特征
原工程训练部分的代码不长,核心逻辑如下:
tn = 30; % 森林中的决策树棵数 sn = 900; % 每棵树使用的训练样本数 S = xlsread('loan_train.xls'); rnode = cell(tn, 1); % 每棵树的节点内容 rchild_value = cell(tn, 1); % 每棵树每个分支的取值列表 rchild_node_num = cell(tn,1); % 每个分支指向的子节点编号 for j = 1:tn % 从 6 个特征里随机抽 5 个,返回乱序排列 Feature = randperm(6, 5); % 有放回地抽取 900 个训练样本索引,范围 1 到 1500 Sample_num = randi([1, 1500], 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先解释randperm(6, 5):它从 1 到 6 中随机选出 5 个不重复的数字,顺序也是随机的。这里选 5 而不是 6,是为了让每棵树在分裂时少看一个特征,增加树与树之间的差异。如果选全部 6 个特征,每棵树在样本相同的情况下几乎会长成同一棵结构,集成就没有意义。
再看randi([1, 1500], 1, sn):这个函数在 1 到 1500 之间随机生成 900 个整数,允许重复,也就是有放回抽样。按随机森林的经典理论,有放回抽样能让每棵树只见到约 63.2% 的原始样本,剩下约 36.8% 的样本可以作为袋外数据,用来估计误差和特征重要性。
最后看存储结构:rnode、rchild_value、rchild_node_num都是tn x 1的 cell 数组,每个 cell 里放一棵完整的树。为什么用 cell 而不是 struct?因为树的节点数不固定,30 棵树深度各不相同,cell 可以天然容纳变长结构。
3.2 ID3 的信息增益计算:核心只有三条公式
这套代码里 ID3 是被调用的核心函数。它的做法是:每次从当前可用特征中,选一个信息增益最大的特征作为分裂节点,然后按该特征的取值把样本分到不同分支,再递归下去。
信息增益的源头是信息熵。原工程里计算期望信息的函数是:
function I = Exp(CN, A) % CN 是标签列(1/2/3),A 是当前子集的行索引集合 CNRowNum = CN_sta(CN, A); % 统计三类标签各有多少个 n = length(A); I = 0; for i = 1:3 if CNRowNum(i) > 0 p = CNRowNum(i) / n; I = I - p * log2(p); end end endExp计算的是当前样本集合的“期望信息”,也就是类别的熵。当三类样本数量接近时,I接近最大值log2(3);当某一类占绝大多数时,I趋近于 0。
有了父节点熵,再算某个特征划分之后的条件熵。原工程里的avg_entropy函数把样本按特征取值分组,对每一组分别计算熵,再按样本量加权平均。父节点熵减去条件熵,就是信息增益。ID3 在每一步选择信息增益最大的特征。
这套逻辑对离散特征非常自然:特征取值是有限的几类,分组后每组样本数可数,熵可以精确计算。这也是为什么项目文档最后强调“只适用于属性值是标签值也就是离散值的类型”。如果遇到连续值,一个取值一个组,树会为了拟合每一个数值疯狂分裂,严重过拟合。
3.3 停止条件:四个条件守住“不再分裂”的边界
只看信息增益还不够,树如果无限长下去,训练集准确率能到 100%,但测试集一定崩。原工程的TreeNode函数里写了几个停止条件:
- 当前子集为空,说明这个分支没有样本,直接返回空节点。
- 可用特征已经用完,就取当前子集中出现次数最多的类别作为叶子。
- 当前子集所有样本都属于同一类,不需要再分裂。
- 所有候选特征的信息增益都小于等于 0,说明再分下去没有信息收益,也停止。
第 4 条容易被忽略。实际数据里可能出现某个特征对当前分类毫无区分度,计算出来的增益是负数。原代码用max(Gain) <= 0拦截这种情况,避免强行分裂。
调试时可以打印每棵树的根节点特征,看看 30 棵树是否覆盖了不同特征:
for j = 1:tn fprintf('Tree %02d root: %d\n', j, rnode{j,1}{1}); end如果所有根节点都集中在同一个特征上,说明该特征对标签的区分度太强,也可能说明randperm的随机性没有充分起作用。
4. 分类与投票统计:从 30 棵树的输出到 91.6% 准确率
训练森林只是前半程,真正决定业务结果的是后半程:每一棵树如何对贷款申请者走出一条路径,30 条路径又如何汇总成一个决定。
4.1 多数投票和平票处理
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平票处理是一个容易被忽略的细节。find返回所有达到最大值的下标,原代码取maxn(1),也就是类别编号较小的那个。比如“同意”和“拒绝”各得 15 票,最终会输出“同意”。这个策略工程上可以接受,但如果你想让平票时偏向“拒绝”,或者随机选一个,需要在这里修改。
在金融风控场景里,不同类别的误分类代价完全不同。拒绝一个本应同意的客户,损失的是潜在收益;同意一个本应拒绝的客户,损失的是本金。如果你希望保守一点,可以把少数类的投票权重调高,比如在TypeNum累加时对第 3 类加 1.2。但这属于业务调优,课程设计里不需要。
4.2 单棵树行走逻辑:特征匹配和兜底分支
vote函数负责让一个测试样本在一棵树上从根走到叶。原代码的写法有点绕,我重写成了可读性更高的版本,逻辑是一致的:
function [type] = vote(rnode, rchild_value, rchild_node_num, PValue, j) n = 1; % 从根节点开始 while ~isempty(rchild_node_num{j,1}{n}) node_attr = rnode{j,1}{n}; % 当前节点的特征编号 branch_values = rchild_value{j,1}{n}; % 该特征下的分支取值 child_nodes = rchild_node_num{j,1}{n};% 分支对应的子节点编号 found = false; for i = 1:length(branch_values) if PValue(node_attr) == branch_values(i) n = child_nodes(i); found = true; break; end end if ~found % 特征值不在分支列表里,常见做法是微调取值再试 PValue(node_attr) = roundn(PValue(node_attr) + 0.1, -1); end end type = rnode{j,1}{n}; end这里的核心是按特征编号取值,再和当前节点的分支取值做相等比较。因为 ID3 生成树时每个节点只关心一个特征,所以测试样本不需要一次看完 6 个特征,只需要在路径上用到的那些特征值。
~found分支是原代码里一个比较“野”的兜底:如果某个特征值在树中不存在,就对它加 0.1,再四舍五入保留一位小数,尝试匹配相邻取值。这在课程设计里能让个别脏数据继续跑完,但我不建议在生产环境直接用这个逻辑。更好的做法是遇到未知取值时返回该节点的多数类,或者直接报错提醒数据编码有问题。兜底逻辑会掩盖测试集和训练集编码不一致的事实,导致最终准确率看起来还行,但业务上线后一片混乱。
4.3 结果回写与准确率计算
测试样本逐条预测完后,原工程用xlswrite把结果写到 Excel:
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', len, count); fprintf('准确率为:%.2f%%\n', count / len * 100);[T type]把原始测试样本和预测类别拼在一起输出。这样出了问题可以直接在 Excel 里逐行比对。原工程跑 500 个测试样本,正确 458 个,准确率 91.6%。
如果只看一个准确率数字,会漏掉很多信息。比如三类样本各自的预测正确率如何,是“同意”分错的多,还是“拒绝”分错的多。建议加一个混淆矩阵:
cm = confusionmat(gd, type); disp(cm);输出矩阵的行是真实类别,列是预测类别。对角线越集中越好。如果某一类经常被误判成另一类,说明特征对该类的区分度不足,或者训练样本里该类数量太少。
5. 避坑与常见问题排查:五个让随机森林“翻车”的玄学节点
这份代码我实际跑过,也见过别人在复现时改坏的版本。问题集中出现在数据清洗、全局变量和特征编码三个层面。下面这五个坑,按“现象 → 原因 → 解决”写出,你可以对照排查。
5.1 数据层:Excel 表头、标签混入、编码不一致
坑 1:准确率忽高忽低,甚至训练时直接报错。
现象:xlsread读入后,S(1,:)全是 NaN,抽样抽到这一行后 ID3 的熵计算报错。
原因:Excel 首行是表头,不是数据。xlsread默认把首行当成数值读取,读不成就变成 NaN。
解决:读取后先判断第一行是否含 NaN,如果是就删除。前面写的loadLoanSheet函数可以直接复用。
坑 2:准确率飙到 99%,但树结构明显不对。
现象:打印根节点时发现第 7 列经常被当成分裂特征,或者测试集和训练集预测几乎全对。
原因:预处理时没有把标签列和特征列分开,ID3 把第 7 列的“贷款结果”也当作输入特征了。
解决:检查DValue = S(:, 1:6),确认标签只进入CN,不进入特征矩阵。换数据集时尤其要小心,不要用“最后一列”这种想当然的写法,而是按列名明确区分。
坑 3:测试集单独预测时,大量样本被分到最近值兜底分支。
现象:vote 函数里PValue(node_attr)总是匹配不上,几乎每个样本都走+0.1的微调逻辑。
原因:训练集和测试集某个特征的编码不一致。比如训练集收入用 0、1、2,测试集却用“低 / 中 / 高”转成的 1、2、3。
解决:建模前对每个 Excel 文件分别调用unique,对比各列取值。贷款标签列尤其要检查,1、2、3 的顺序任何一边对不上,投票结果都会乱。
5.2 模型层:全局变量残留、连续值硬塞
坑 4:同一份代码跑两次,第二次生成的树和服务器的树对不上。
现象:第一次跑准确率正常,第二次跑同一个脚本时rnode里的节点数量莫名变多,树越跑越深。
原因:ID3 和 TreeNode 函数用global node child_value child_node_num存储树。全局变量在工作区之间共享,第二次调用时上次留下的树节点还在,递归继续往上追加,导致树结构叠加。
解决:在 ID3 函数入口处先初始化全局变量:
global node child_value child_node_num node = {}; child_value = {}; child_node_num = {};确保每次调用都从空树开始。如果你不打算用全局变量,把这套代码改写成函数返回值递归传递也行,但改动量较大,建议先保留 global 并强制清空。
坑 5:把连续属性硬塞给这套 ID3,结果是训练集 100%、测试集 78%。
现象:数据集里有个特征不是 1、2、3 这样的标签,而是类似年龄、收入额这样的连续值。树深度变得非常大,每个节点只带走几个样本。
原因:ID3 按“特征取值相等”分组,连续值几乎每个样本一个值,信息增益计算会把每个值都当成一个分支,树被过拟合。
解决:连续特征先分箱,比如把年龄切成[<=25, 26-35, 36-50, >50]四个离散标签。或者换用 CART 算法的阈值分裂逻辑,而不是 ID3。原工程文档也明确指出,这个实现“只适用于属性值是标签值也就是离散值的类型”。
6. 进阶验证:用 K 折交叉验证把 91.6% 钉成稳定值
原工程只在固定的 500 条测试集上算了一次准确率。这个数字看着不错,但它依赖一次随机抽样,换一个测试集很可能会掉到 88% 或跳到 93%。更稳的做法是把 1500 条训练样本也拿来做交叉验证,评估模型在“没见过数据”上的真实表现。
思路是这样的:把 1500 条样本分成 5 折,每次用 4 折训练、1 折测试,记录准确率,重复 5 次,最后取平均。如下是复用原工程训练和投票逻辑的骨架:
rng(2024); data = loadLoanSheet('loan_train.xls'); X = data(:, 1:6); y = data(:, 7); folds = 5; indices = crossvalind('Kfold', y, folds); acc = zeros(folds, 1); for k = 1:folds testIdx = (indices == k); trainIdx = ~testIdx; % 每棵树只从 trainIdx 对应的行里抽样 trainData = data(trainIdx, :); for j = 1:tn Feature = randperm(6, 5); sampleIdx = randi([1, sum(trainIdx)], 1, sn); SData = trainData(sampleIdx, :); [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 % 对 testIdx 里的样本逐条调用 statistics % 得到 pred 后计算 acc(k) end fprintf('K折平均准确率:%.2f%%\n', mean(acc) * 100);这里有一个关键改动:randi的上界要从 1500 改成当前训练折的行数sum(trainIdx),否则会抽出已经被剔除到测试折里的样本。
跑通交叉验证后,可以做参数敏感性测试。重点看两个参数:
第一个是每棵树使用的特征数。原工程用randperm(6, 5),也就是每棵树只看 5 个特征,随机性其实很弱。建议改成 4 或 3,对比交叉验证平均准确率。6 个特征时,每棵树只忽略 1 个特征,30 棵树之间的相关性偏强;减少到 3 个特征后,每棵树更“弱”,但树之间的差异更大,集成效果不一定差。
第二个是树的棵数。现在tn = 30,可以往两个方向测:tn = 10看“少而弱”的下限,tn = 50或100看是否随着树增多准确率继续上升。注意,树增多后训练时间线性增加,但投票结果会趋于稳定,如果 50 棵和 30 棵的交叉验证准确率只差 0.2%,就不要选择更大的参数。
从那以后,我每次拿到随机森林工程,第一件事不是看准确率,而是先跑一遍 K 折交叉验证,并固定随机种子;否则 91.6% 可能只是运气好的一次抽样,而不是模型的真实能力。这个习惯帮我避开了很多假象,希望帮到你。
本文还有配套的精品资源,点击获取