news 2026/10/18 3:23:43

两百行MATLAB实现随机森林:从基尼分裂到袋外评估全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
两百行MATLAB实现随机森林:从基尼分裂到袋外评估全解析

简介:面向MATLAB初学者的随机森林分类算法入门实现,基于统计与机器学习工具箱编写,直观演示决策树集成、自助抽样、随机特征选择与多数投票等核心机制,可快速用于二分类或多分类建模。压缩包共八个文件,主体为三个M脚本,配套三个XLS格式贷款数据集以及两个ASV备份文件,整体大小约四十四KB,结构简洁便于阅读。目前已有四千九百六十四人学习,适合初次接触集成学习或希望用MATLAB完成分类实践的开发者。通过运行随机森林主脚本可构建并训练分类器,结合决策树脚本理解单棵树的生成逻辑,辅助脚本则提供统计支持;自带训练集、测试集与结果集可直接演练预测、混淆矩阵和特征重要性分析,调整树数量与特征抽样数还能观察模型效果变化。

1. 别把随机森林当黑匣子:一个两百行看懂原理的 MATLAB 实现

作为一个把 MATLAB 当主力开发语言的人,我在不少项目里见过同一种尴尬:分类任务做完了,论文里的算法描述却只能引用几行工具箱文档,因为没人知道封装内部到底发生了什么。这份源码把随机森林从黑匣子拆成了透明的 .m 文件——不依赖任何机器学习工具箱,训练、预测、袋外评估全部手写,核心算法只有两百行左右。它解决的是“我想看懂随机森林,并且能在 MATLAB 里亲手改一轮参数”这件事。适合要做课程设计或论文复现的人,也适合那些想给现有分类任务换成自定义分裂规则,而不是被动接受默认配置的人。

2. 技术栈与整体架构:不依赖工具箱,训练预测评估三个入口全打通

2.1 为什么不用 TreeBagger 而是纯手写

MATLAB 自带的 TreeBagger 功能完整,底层是 C 编译的决策树库,跑起来快,参数也多。但问题在于它是个黑匣子:你传入数据和参数,拿回一个模型对象,想要看某个节点分裂时用的是哪个特征、阈值怎么算出来的,非常费劲。

这个项目选择纯手写,核心动机是可以观测、可以改。开发语言只有 MATLAB,零工具箱依赖,代码里只有 struct 和 cell 这类基础容器。用 classdef 写面向对象也可以,但 struct 在递归建树时更直接:一个树节点就是一个结构体,左右子树就是结构体里的两个字段,递归终止时填上标签即可。对于教学和二次开发来说,这种“数据结构即模型结构”的做法理解成本最低。

2.2 文件布局与数据约定

源码按训练、预测、评估、工具函数四个维度组织,核心入口只有三个函数,其他都是为它们服务的递归函数。整体文件布局如下:

文件名职责关键输入输出
rfTrain.m训练入口X, y, numTrees, numFeatures, minLeaf,返回 model
rfPredict.m预测入口model, XNew,返回类别向量
rfOOB.m袋外精度评估model, X, y,返回 acc
buildTree.m递归建树当前样本、候选特征、深度,返回 node
findBestSplit.m查找最优分裂当前样本、候选特征,返回特征索引与阈值
giniNode.m计算基尼系数标签向量,返回基尼值
demo_iris.m完整演示脚本无参数,跑完输出精度和模型信息

数据约定上,特征矩阵 X 是 m×n 的 double 矩阵,一行一个样本;标签 y 是 m×1 的整数列向量,类别必须编码成 1 到 k 的连续正整数。这个约定很重要,后面避坑部分会专门说明为什么不能直接从 0 开始编号。

2.3 模型结构:树即 struct,森林即 cell

训练完成后,model 这个结构体承载了整个森林:

model.trees = cell(numTrees, 1); % 每一棵树是一个 struct model.oobIdx = cell(numTrees, 1); % 每棵树对应的袋外样本索引 model.numClasses = k; % 类别总数 model.classNames = classes; % 原始标签,方便预测后翻译回去

每一棵树又是嵌套的 struct:根节点包含 feat、thresh、left、right、label、isLeaf 六个字段,left 和 right 各自又是一个同构的节点结构体。叶子节点上 isLeaf 为 true,label 为具体类别;内部节点上 feat 和 thresh 才有意义。

这种设计的直接好处是,你想打印一棵树的结构,只需要递归遍历 struct 就能输出文本,不需要解析任何私有数据格式。想改成三叉树,也只需要给节点加一个 mid 字段,处理路径同步调整即可。

3. 核心代码拆解:从基尼分裂到多数投票的完整链路

3.1 节点设计:一个结构体装下整棵树

先看节点定义。这个函数不承担任何逻辑,只是初始化一个干净的节点骨架:

function node = createNode() % 新建一个树节点结构体 node.feat = 0; % 分裂特征在样本矩阵中的列索引 node.thresh = 0; % 分裂阈值,左子树取小于阈值的样本 node.left = []; % 左子节点,内部节点递归填充 node.right = []; % 右子节点 node.label = -1; % 叶子节点上的类别,内部节点保持 -1 node.isLeaf = false; % 是否为叶子 end

这里用 struct 而不是手写 class,原因是 buildTree 递归时只需要反复赋值字段,struct 的拷贝成本在中小数据集上完全可接受。如果你接手这个项目跑十万级样本,再考虑把 struct 换成 handle 类也不迟,接口完全不用变。

3.2 找最优分裂:基于 unique 候选阈值的基尼增益计算

分裂的核心是基尼增益。每个内部节点要回答两个问题:在哪个特征上分裂?阈值取多少?findBestSplit 完成这件事:

function [bestFeat, bestThresh, bestGain] = findBestSplit(Xs, ys, featSel, minLeaf) % Xs: 当前节点样本特征 % ys: 当前节点标签 % featSel: 本次允许使用的特征索引集合 parentGini = giniNode(ys); n = length(ys); bestGain = -Inf; bestFeat = -1; bestThresh = 0; for j = featSel col = Xs(:, j); cands = unique(col); % 候选阈值来自特征本身的取值 for g = 1:length(cands)-1 th = (cands(g) + cands(g+1)) / 2; % 相邻值取中点 left = col < th; right = ~left; if sum(left) < minLeaf || sum(right) < minLeaf continue; % 两侧样本数都不能低于 minLeaf end gain = parentGini - ... (sum(left)/n * giniNode(ys(left)) + ... sum(right)/n * giniNode(ys(right))); if gain > bestGain bestGain = gain; bestFeat = j; bestThresh = th; end end end if bestGain < 1e-6 bestFeat = -1; % 找不到有效分裂,交给建树逻辑处理 end end

候选阈值为什么取相邻唯一值的中间点?这是决策树处理连续特征的标准做法:对一列特征排序后,真正可能改变样本分布的位置只出现在相邻取值之间,取中点既能保证二分裂的完整性,又比遍历所有样本值快一个量级。

对应的基尼系数计算很简单,用 histcounts 做概率估计即可:

function g = giniNode(y) % 计算标签向量 y 的基尼系数 if isempty(y) g = 0; return; end p = histcounts(y, 'Normalization', 'probability'); g = 1 - sum(p.^2); end

基尼系数越小,节点的纯度越高。分裂增益就是分裂前的基尼值减去分裂后左右节点基尼值的加权和。这个公式直接对应周志华《机器学习》里的随机森林章节,看书时对照这份代码会非常顺畅。

3.3 递归建树:终止条件、叶子标签与最小叶子数

有了最优分裂,建树就成了标准递归:

function node = buildTree(Xs, ys, featSel, minLeaf, maxDepth, depth) node = createNode(); labels = unique(ys); if length(labels) == 1 || size(Xs, 1) < minLeaf || depth >= maxDepth node.isLeaf = true; node.label = mode(ys); % 叶子标签取众数 return; end [feat, th, gain] = findBestSplit(Xs, ys, featSel, minLeaf); if feat == -1 node.isLeaf = true; node.label = mode(ys); return; end node.feat = feat; node.thresh = th; leftIdx = Xs(:, feat) < th; node.left = buildTree(Xs(leftIdx, :), ys(leftIdx), featSel, minLeaf, maxDepth, depth + 1); node.right = buildTree(Xs(~leftIdx, :), ys(~leftIdx), featSel, minLeaf, maxDepth, depth + 1); end

终止条件有四个:纯度已经达到 100%、剩余样本少于 minLeaf、深度到达 maxDepth、找不到有效分裂。任何一个触发,当前节点直接变叶子。叶子标签用 mode 取众数,因为这是分类任务,类别没有数值大小意义,不能像回归树那样取均值。

一个值得注意的细节是:minLeaf 不只控制叶子大小,它还在 findBestSplit 里作为分裂合法性的前置条件。如果分裂后某一侧样本数太少,这个分裂点直接被跳过。这样整棵树不会长出一个只有一条样本的孤立叶子,泛化能力会好很多。

3.4 装袋采样与特征随机选择:随机性的两个来源

随机森林和单棵决策树的区别,就是它同时做两件随机化的事:对样本装袋,对特征抽样。训练入口 rfTrain 把这两步放在一起:

function [idxTrain, idxOOB] = bootstrapSample(n) % 有放回采样 n 个样本 idxTrain = randsample(n, n, true); % 没被抽中的样本作为袋外索引 idxOOB = setdiff((1:n)', unique(idxTrain)); end
function model = rfTrain(X, y, numTrees, numFeatures, minLeaf, maxDepth) % 默认参数处理 if nargin < 5, minLeaf = 5; end if nargin < 6, maxDepth = 20; end [n, d] = size(X); model.trees = cell(numTrees, 1); model.oobIdx = cell(numTrees, 1); model.numClasses = length(unique(y)); for t = 1:numTrees [idxTrain, idxOOB] = bootstrapSample(n); featSel = randperm(d, min(numFeatures, d)); model.trees{t} = buildTree(... X(idxTrain, :), y(idxTrain), featSel, minLeaf, maxDepth, 0); model.oobIdx{t} = idxOOB; end end

bootstrapSample 用 randsample 实现有放回抽样,每个样本被抽到的期望概率约为 1 - 1/e,所以每棵树大约有三分之一的样本永远不会参与建树,它们就是天然的验证集。featureSel 用 randperm 从全部特征里无放回抽 numFeatures 个,这一步让树与树之间的形状产生差异,从而降低森林的方差。

3.5 预测阶段的多数投票:避免逐样本循环的技巧

预测时每棵树独立给一个类别,然后按多数投票决定最终结果:

function pred = rfPredict(model, X) n = size(X, 1); votes = zeros(n, model.numClasses); for t = 1:length(model.trees) v = treePredict(model.trees{t}, X); idx = sub2ind(size(votes), (1:n)', v); votes(idx) = votes(idx) + 1; end [~, pred] = max(votes, [], 2); end function pred = treePredict(node, X) n = size(X, 1); pred = zeros(n, 1); for i = 1:n p = node; while ~p.isLeaf if X(i, p.feat) < p.thresh p = p.left; else p = p.right; end end pred(i) = p.label; end end

treePredict 对每个样本走一遍从根到叶子的路径,while 循环跳转。多数投票这里用了一个批量技巧:sub2ind 把二维索引转成线性索引,votes 一次性累加,避免了用两个嵌套循环逐样本逐类计数,速度提升明显。

4. 运行起来:iris 数据的训练、调参与模型体检

4.1 数据准备与训练调用

项目自带的是 iris 数据集演示,直接用 MATLAB 内置的 fisheriris 加载后做编码和划分:

load fisheriris; X = meas; % 150x4 的特征矩阵 y = zeros(size(species)); % 原始标签是元胞字符串,这里映射成 1, 2, 3 classes = unique(species); for i = 1:length(classes) y(strcmp(species, classes{i})) = i; end % 固定随机种子,保证结果可复现 rng(42); idx = randperm(150); trainIdx = idx(1:120); testIdx = idx(121:end); % 训练 50 棵树,每棵随机抽 2 个特征,叶子最小样本数 3 model = rfTrain(X(trainIdx, :), y(trainIdx), 50, 2, 3); % 测试集预测 pred = rfPredict(model, X(testIdx, :)); acc = mean(pred == y(testIdx)); fprintf('Test accuracy: %.4f\n', acc);

trainIdx 和 testIdx 的划分直接决定后续对比是否公平。iris 数据本身三个类别在特征空间里区分度较高,随机森林在 120 个训练样本上通常能跑到 93% 到 97% 的测试精度,这个浮动范围主要来自装袋随机性,后面避坑部分会展开说。

4.2 参数怎么设:numTrees、numFeatures、minLeaf、maxDepth

四个核心参数对模型行为的影响各不相同,实际调参时我一般按下面这张表来估计方向:

参数常见取值对模型的影响
numTrees50~200越多越稳定,但训练时间线性增长,50 棵之后精度增量很小
numFeaturesceil(sqrt(d))越小树间相关性越低,但单棵树容量下降
minLeaf总样本的 1%~5%越小树越深,过拟合风险越大
maxDepth15~25限制树高,对高维稀疏特征尤其有效

numTrees 在这个实现里和训练时间成正比,因为每棵树都是独立 for 循环建的,没有并行优化。调试时先用 30 棵树跑通流程,确认没问题再加大到 100 以上。numFeatures 如果取 d,随机森林会退化成袋装决策树,精度反而下降,这点放在避坑章重点说。

4.3 观察模型内部:看树是怎么长出来的

训练完成后,可以写个小函数把任意一棵树打印成缩进文本:

function printTree(node, depth) if node.isLeaf fprintf('%sLeaf class %d\n', repmat(' ', 1, depth), node.label); else fprintf('%sFeature %d < %.2f\n', repmat(' ', 1, depth), node.feat, node.thresh); printTree(node.left, depth + 1); printTree(node.right, depth + 1); end end

调用 printTree(model.trees{1}, 0) 就能看到第一棵树的完整分裂路径,比如“Feature 3 < 2.45”下面挂着两个叶子。这种体检方式在 TreeBagger 里需要额外封装,手写实现天然支持。看几棵树之后你会发现,不同树的第一刀往往选在不同特征上,这正是特征随机化的直观体现。

5. 避坑指南:随机森林在 MATLAB 里最容易翻车的五个细节

5.1 现象:每次跑精度都不一样,以为代码写错了

明明数据没变,第一次跑测试精度 95%,第二次变成 93%,第三次又回到 95%。这不是模型随机性的正常范围,而是随机种子没有固定,导致装袋采样和特征选择的随机序列完全漂移。

原因在于 randsample 和 randperm 都依赖 MATLAB 的全局随机数流,而默认情况下每次启动都有不同种子。解决方法是训练前统一固定 rng,比如脚本开头写rng(42)。做参数对比实验时,固定种子尤其重要,否则无法判断精度变化来自参数还是来自随机波动。

5.2 现象:特征矩阵里有 NaN,OOB 精度虚高但测试集崩掉

训练过程没有报错,OOB 精度看起来很高,但一上测试集直接泛化失败。检查代码后发现问题出在分裂判断上:MATLAB 里NaN < th的结果是 false,所有包含 NaN 的样本会被一致地推到右子树,分裂逻辑被这些无效值带偏。

解决方法是数据进入训练前先做清洗,比如按列均值填充,或者直接删掉含 NaN 的行。更稳妥的做法是在 findBestSplit 里加一行过滤,把非有限值样本暂时排除在分裂计算之外。这个坑在真实业务数据里极常见,iris 这种干净数据集不会暴露。

5.3 现象:numFeatures 设成总特征数,森林退化成袋装树

有人觉得“既然是随机森林,把每棵树能用的特征放宽到全部,信息更充分”,结果测试精度不升反降。原因是随机森林的核心优势来自树之间的去相关,而特征随机化就是去相关的主要手段。当所有树都能在每个节点选择全部特征时,树的结构会高度相似,本质上退化成了 Bagged Trees。

解决方法是把 numFeatures 控制在总特征数的平方根附近,iris 数据 4 个特征时取 2,特征数更多时按ceil(sqrt(d))向上取整。是否需要加大,应该通过 OOB 精度曲线来判断,而不是凭直觉直接拉满。

5.4 现象:标签从 0 开始编号,预测阶段索引越界

二分类数据里有人习惯把标签写成 0 和 1,训练没报错,但执行到投票累加时直接抛出 sub2ind 索引越界。这是因为 votes 矩阵的列数由类别数决定,列号从 1 开始,标签 0 让线性索引计算失效。

解决方法是所有标签统一编码成 1 到 k 的连续正整数。写一个预处理行[~, ~, y] = unique(y)或者手动重新映射,保留一份 classNames 映射表用于预测结果翻译。从零开始编号的问题也提醒了我:数据编码约定最好在项目 README 里写清楚,避免后面接手的人重复踩坑。

5.5 现象:OOB 精度当成测试精度,调参一时爽,上线两行泪

OOB 精度是随机森林自带的袋外评估,它不需要划分验证集就能算出来,这是它的优点,但也容易误导人。OOB 样本在训练过程中没有参与建树,但特征选择时整个训练集的统计信息已经参与了节点分裂,OOB 精度本身存在轻微乐观偏差。

解决方法是坚持把数据划分为训练集和独立测试集,OOB 只用来做参数对比和树数量选择,最终汇报的精度一律采用测试集。这个习惯能避免很多“训练时 90%,到了真实环境只有 70%”的项目事故。

6. 二次开发:换指标、加特征重要性、把树导出来看

6.1 把基尼指数替换成信息熵,三分钟完成

随机森林不强制用基尼指数,信息熵在部分数据集上表现更稳定。改法非常简单,只需新增一个 entropy 函数:

function e = entropyNode(y) p = histcounts(y, 'Normalization', 'probability'); p = p(p > 0); % 去掉零概率,避免 log2(0) e = -sum(p .* log2(p)); end

然后把 findBestSplit 里的 giniNode 全部换成 entropyNode,parentGini 改成 parentEntropy 即可。因为代码里分裂逻辑和指标是解耦的,两个函数输入输出结构一致,替换成本极低。这也是当初用独立工具函数组织代码的原因。

6.2 加一个特征重要性输出

特征重要性是随机森林的标配能力。简单实现可以用置换重要性:打乱某一列特征,观察精度下降幅度,下降越多说明该特征越重要。

function imp = rfFeatureImportance(model, X, y) baseAcc = mean(rfPredict(model, X) == y); d = size(X, 2); imp = zeros(d, 1); for j = 1:d Xp = X; Xp(:, j) = X(randperm(size(X, 1)), j); % 打乱第 j 列 imp(j) = baseAcc - mean(rfPredict(model, Xp) == y); end end

这个函数只能给出相对重要性排序,不能解释绝对贡献,做特征筛选时用它降序排序足够了。

6.3 导出决策树的文本结构,喂给可视化工具

前面 printTree 已经能把树结构打成文本,如果你想画成图表,可以稍微改一下输出格式,生成点文本供外部渲染。常见做法是让每个节点输出一行规则和两个分支的父子关系,这样文本天然就能被多数图形工具解析。把树的 struct 转成这种半结构化文本,比直接截图树形控件要灵活得多,也方便后续做规则提取和模型解释。

从一个手写的随机森林项目里,我学到最有用的一件事不是算法本身的代码,而是“不要相信无法观测的模型”。从那以后我每次训练随机森林都会先固定随机种子,跑通流程看一眼树结构,再做 OOB 调参,最后才上测试集给结论;这套流程我几乎原样搬到了用其他语言实现的项目里。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/18 3:22:02

海康威视C++ SDK工程骨架:登录/取流/云台控制全链路实践

简介&#xff1a;本资源是海康威视HCNetSDK V5.1.1.4&#xff08;2015年4月20日发布的64位Windows中文版&#xff09;完整C开发包&#xff0c;面向安防监控领域初/中级C开发者&#xff0c;解决视频设备接入、实时预览、云台控制、录像回放及报警处理等核心二次开发需求。压缩包…

作者头像 李华
网站建设 2026/10/18 3:19:52

Godot 2D游戏开发:从Demo到可玩原型,用GameManager与信号搭建游戏循环

跟着这个系列一路做到现在&#xff0c;你应该已经在 Godot 里搭出了一个能控制的 2D 角色&#xff1a;它能移动、能跳跃、能和地图碰撞体打交道&#xff0c;摄像机跟在屁股后面跑&#xff0c;甚至可能已经给角色挂上了一套 idle/run 切换的动画。但说句实话&#xff0c;到这个阶…

作者头像 李华
网站建设 2026/10/18 3:18:37

systemd报错control process exited:RabbitMQ启动失败排查指南

1. 这个 systemd 报错到底在说什么1.1 "control process" 指的不是 RabbitMQ 本身先说个容易混淆的概念。systemd 在启动一个服务时&#xff0c;真正执行的是 unit 文件里 ExecStart 指定的命令。对 RabbitMQ 来说&#xff0c;这个命令通常是/usr/sbin/rabbitmq-serv…

作者头像 李华
网站建设 2026/10/18 3:16:45

Win10 C盘空间告急?系统性清理与数据迁移全攻略

C盘红了这四个字&#xff0c;对任何一个Win10用户来说&#xff0c;比闹钟提醒还让人头疼。明明没装多少软件&#xff0c;空间却一天比一天少。我这些年帮朋友处理过无数次这类问题&#xff0c;自己也踩过不少坑&#xff0c;今天就把从诊断到清理、从系统自带功能到隐蔽项目的完…

作者头像 李华
网站建设 2026/10/18 3:14:38

Java+SpringBoot+HTML5农业信息学习平台毕业设计实战指南

1. 先拆掉"农业信息学习平台"这七个字&#xff1a;它到底是个什么系统做了这么多年毕设辅导相关的技术工作&#xff0c;我见过太多同学拿到题目后第一反应就是打开搜索引擎找现成代码。但"基于 JavaSpringBootHTML5 的农业信息学习平台"这种题目&#xff0…

作者头像 李华