简介:这份资源面向具备一定MATLAB基础、希望亲手实现随机森林(RF)的机器学习学习者与工程实践者,提供一套可直接运行的集成学习代码,用于在自有数据集上完成分类与回归任务的训练和预测。压缩包共14个文件、约211KB,以m函数脚本、txt示例数据、f底层源码、dll动态库及readme、doc文档为主:m文件承载回归与分类主流程,txt提供示例数据,Fortran源码与dll封装计算核心以加速大数据集处理,文档则说明环境配置与调用方式。已有6075人学习下载,说明其在入门与复现场景中具备一定参考价值。读者可借助示例脚本走通数据预处理、模型训练、特征重要性与结果评估的完整链路,并通过阅读源码理解随机采样、决策树生成与输出整合的实现细节,同时可结合打印函数观察模型内部结构,为调参与性能优化提供依据。
1. 随机森林 MATLAB 代码:从一份能跑的分类器源码说起
手头这份随机森林 MATLAB 代码,核心价值不在于“随机森林”四个字有多新,而在于它把 RF 从论文公式落成了一套能在 MATLAB 里直接跑通、能改、能换数据集的工程实现。很多同学第一次接触随机森林,卡的不是算法思想,而是“决策树怎么分裂、袋外样本怎么算、特征子集怎么随机抽”这些细节在代码里到底长什么样。这份源码正好把这些环节摊开给你看:训练入口、树的构建、节点分裂准则、预测投票、精度评估一应俱全。它适合两类人——一类是课程设计或毕设需要交一个能跑的分类器,另一类是想拿 MATLAB 做算法原型验证、又不想从零手写基尼指数的开发者。下面按“能干什么、怎么跑、怎么改”的顺序拆。
2. 项目结构与运行链路:从入口脚本到一棵树的生长
2.1 目录与文件职责划分
这份代码是典型的 MATLAB 脚本型工程,没有复杂的包管理,文件平铺在同一个工作目录下,靠函数名互相调用。常见做法是拆成这么几块:一个主入口脚本负责加载数据、划分训练测试集、调训练函数、算精度并画图;一个训练函数负责按树的数量循环建树;一个建树函数递归地选最优分裂特征和阈值;一个预测函数让每棵树投票;再加一两个辅助函数处理数据归一化或混淆矩阵。你拿到源码后先别急着运行,用dir或资源管理器把文件列一遍,按“入口—训练—建树—预测—评估”五类归一下,后面读代码就不会迷路。
| 文件类型 | 典型命名 | 职责 |
|---|---|---|
| 入口脚本 | main.m / run_rf.m | 加载数据、切分、调训练与预测、输出精度 |
| 训练函数 | trainRF.m | 控制树数量、样本自助采样、循环建树 |
| 建树函数 | buildTree.m | 递归选分裂特征与阈值,生成节点 |
| 预测函数 | predictRF.m | 样本过每棵树,多数投票 |
| 辅助函数 | gini.m / splitData.m | 基尼指数、数据切分、混淆矩阵 |
2.2 主入口的执行顺序
入口脚本决定了整个项目的运行链路,读它比读任何单个函数都快。下面这段是这类项目里最常见的骨架,我按源码结构还原出来,字段名可能和你手上的略有出入,但调用关系基本一致:
% main.m —— 随机森林主入口 clear; clc; close all; % 1. 加载数据(假设数据最后一列为标签) data = load('dataset.mat'); X = data.features; % 特征矩阵,行=样本,列=特征 y = data.labels; % 标签向量 % 2. 划分训练集与测试集(7:3) cv = cvpartition(y, 'HoldOut', 0.3); XTrain = X(training(cv), :); yTrain = y(training(cv)); XTest = X(test(cv), :); yTest = y(test(cv)); % 3. 训练随机森林 numTrees = 100; % 树的数量 model = trainRF(XTrain, yTrain, numTrees); % 4. 预测并评估 yPred = predictRF(model, XTest); acc = sum(yPred == yTest) / numel(yTest); fprintf('测试集精度: %.2f%%\n', acc * 100); % 5. 混淆矩阵可视化 confusionchart(yTest, yPred);这段代码的逻辑很直白:cvpartition做分层留出划分,保证训练测试集类别比例接近;trainRF返回的model是一个结构体,里面存着每棵树的节点信息;predictRF逐样本过树投票。参数上最值得动的是numTrees,树太少方差压不住、精度抖动大,树太多训练时间线性上涨,100 到 200 是这类教学级实现的常见区间。HoldOut的 0.3 也可以改成 0.2 或 0.25,样本量小的时候建议用交叉验证替代单次留出,否则精度评估的玄学波动会让你怀疑代码写错了。
2.3 训练函数里的两个随机性
随机森林之所以叫“随机”,靠的是两处采样:样本层面的自助采样(bootstrap)和特征层面的随机子集。这两处在训练函数里必须同时体现,缺一个就退化成普通 bagging 或单棵决策树。典型实现如下:
function model = trainRF(X, y, numTrees) [nSamples, nFeatures] = size(X); model.trees = cell(numTrees, 1); model.nFeatures = nFeatures; for t = 1:numTrees % 随机性一:有放回抽 nSamples 个样本 idx = randi(nSamples, nSamples, 1); Xboot = X(idx, :); yboot = y(idx); % 随机性二:每次分裂只考虑 sqrt(nFeatures) 个特征 mtry = max(1, round(sqrt(nFeatures))); model.trees{t} = buildTree(Xboot, yboot, mtry); end endrandi(nSamples, nSamples, 1)是有放回抽样,约 36.8% 的样本不会被抽中,这些就是袋外样本(OOB),后面可以用来做无需额外验证集的精度估计。mtry取特征数的平方根是分类任务的经典默认值,回归任务一般取三分之一,这份代码如果是分类器就保持sqrt。这里有个容易翻车的点:mtry如果写成固定值而没跟nFeatures挂钩,换个高维数据集后每棵树几乎一模一样,集成效果直接打折。
2.4 建树函数的递归分裂逻辑
建树是整个项目最核心也最容易读晕的部分。递归函数一般带三个终止条件:节点样本纯度高到不用分、样本数低于阈值、树深达到上限。分裂时遍历候选特征和候选阈值,选基尼指数下降最多的那一刀。下面这段是精简后的骨架:
function node = buildTree(X, y, mtry) node.prediction = mode(y); % 叶节点投票结果 node.isLeaf = true; % 终止条件:纯节点或样本过少 if numel(unique(y)) == 1 || size(X, 1) < 5 return; end [nSamples, nFeatures] = size(X); featIdx = randperm(nFeatures, min(mtry, nFeatures)); % 候选特征子集 bestGain = 0; bestFeat = 0; bestThresh = 0; for f = featIdx thresholds = unique(X(:, f)); for th = thresholds' left = X(:, f) <= th; if all(left) || all(~left), continue; end gain = gini(y) - (sum(left)/nSamples)*gini(y(left)) ... - (sum(~left)/nSamples)*gini(y(~left)); if gain > bestGain bestGain = gain; bestFeat = f; bestThresh = th; end end end if bestFeat == 0, return; end % 找不到有效分裂就停 left = X(:, bestFeat) <= bestThresh; node.isLeaf = false; node.feat = bestFeat; node.thresh = bestThresh; node.left = buildTree(X(left, :), y(left), mtry); node.right = buildTree(X(~left, :), y(~left), mtry); endgini(y)算当前节点的不纯度,1 - sum(p.^2),p 是各类别占比。增益公式是父节点不纯度减去两个子节点不纯度的加权和,权重就是样本占比。randperm那行保证每次分裂只看随机抽的一部分特征,这是随机森林和普通决策树的分水岭。阈值遍历用unique去重能省不少时间,但高维连续特征下仍然是性能瓶颈,后面改造章节会讲怎么优化。注意size(X,1) < 5这个最小分裂样本数,设太小容易过拟合出只含一两个样本的叶节点,设太大又欠拟合,5 到 10 之间按数据量调。
3. 预测与评估:投票、袋外误差和混淆矩阵怎么落地
3.1 单样本过森林的投票流程
训练完的model是一堆树,预测时每个样本从根节点往下走,按feat和thresh判断往左还是往右,走到叶节点拿到该树的预测标签,最后所有树多数投票。实现上就是一个循环加一个mode:
function yPred = predictRF(model, X) nSamples = size(X, 1); nTrees = numel(model.trees); votes = zeros(nSamples, nTrees); for t = 1:nTrees for i = 1:nSamples votes(i, t) = traverseTree(model.trees{t}, X(i, :)); end end yPred = mode(votes, 2); % 按行多数投票 end function label = traverseTree(node, x) while ~node.isLeaf if x(node.feat) <= node.thresh node = node.left; else node = node.right; end end label = node.prediction; endtraverseTree用while而不是递归,避免深树时 MATLAB 递归栈开销。mode(votes, 2)沿第二维取众数,就是每行(每个样本)在所有树里的多数标签。这里有个细节:如果出现平票,mode返回较小值,类别不平衡时可能偏向某一类,严谨做法是平票时按各类概率加权,或者干脆用概率平均替代硬投票。教学级代码一般不管这个,但你要拿去跑真实数据,建议把votes改成存叶节点类别分布,再取argmax平均概率。
3.2 袋外误差:不额外切验证集的精度估计
自助采样留下的那约 36.8% 没被抽中的样本就是 OOB 样本,用它们估计泛化误差几乎不花额外成本。做法是在训练时记录每棵树用了哪些样本,预测时只让没参与该树训练的样本参与该树的 OOB 评估:
% 训练时记录 bootstrap 索引 model.trees{t} = buildTree(Xboot, yboot, mtry); model.oobIdx{t} = setdiff(1:nSamples, idx); % 未被抽中的样本 % 评估时统计 OOB 精度 oobPred = zeros(nSamples, 1); oobCount = zeros(nSamples, 1); for t = 1:numTrees idxOOB = model.oobIdx{t}; preds = arrayfun(@(i) traverseTree(model.trees{t}, X(i,:)), idxOOB); oobPred(idxOOB) = oobPred(idxOOB) + preds; oobCount(idxOOB) = oobCount(idxOOB) + 1; end valid = oobCount > 0; oobAcc = mean(round(oobPred(valid) ./ oobCount(valid)) == y(valid));setdiff拿到 OOB 索引,oobCount记录每个样本被多少棵树当作 OOB,最后取平均。OOB 误差的好处是不用单独切验证集,样本量小的时候特别值钱。注意round那步是简化处理,严格来说应该按每棵树的预测累加后取众数,这里用平均再取整只是示意,你改的时候按类别累加更稳。
3.3 混淆矩阵与特征重要性输出
评估不能只看一个总精度,类别不平衡时总精度会骗人。confusionchart直接出图,配合每类的召回率和精确率看才有意义。特征重要性这份代码里如果有,通常是基于分裂增益累加:每次分裂时把增益记到对应特征上,最后归一化排序。没有的话可以自己加,在buildTree里维护一个全局importance向量,importance(bestFeat) += bestGain * nSamples,训练完除以总样本数即可。这个指标比单纯看精度更能指导你删冗余特征,二次开发时很实用。
4. 避坑与排查:跑不通、精度低、速度慢的常见原因
4.1 报错“未定义函数或变量”
现象是运行入口脚本直接红字报某个函数找不到。原因多半是当前工作目录不在源码所在文件夹,或者函数文件名和内部定义的函数名不一致。MATLAB 靠文件名找函数,trainRF.m里必须定义function model = trainRF(...),名字差一个字母就找不到。解决方法是cd到源码目录再运行,或者用addpath(genpath(pwd))把子目录都加进搜索路径。另外检查有没有把脚本和函数混在一个文件里,脚本文件里定义函数在旧版本 MATLAB 里是不允许的。
4.2 精度在 50% 上下晃,像随机猜
现象是训练集精度很高、测试集精度接近类别比例的倒数。原因通常是数据标签列没对齐,或者特征矩阵里混进了标签列导致信息泄漏,也可能是mtry设成了全部特征、树之间高度相关。排查顺序:先打印size(X)和size(y)确认行数一致;再检查unique(y)看标签是不是数值和字符串混用;最后把numTrees从 10 加到 200 看精度是否收敛。如果加了树精度还是不动,多半是特征本身没区分度,回去做特征工程比调参有用。
4.3 训练慢到跑不完
现象是几百个样本、几十个特征也要跑几分钟。原因在建树函数里对每个候选阈值都做了一次全量比较,复杂度是特征数乘样本数乘阈值数再乘树数。优化手段:阈值不用遍历所有唯一值,等频分箱取 10 到 20 个候选点就够;gini计算用向量化替代循环;样本量大的时候把最小分裂样本数调大提前终止。血泪经验是别一上来就numTrees = 500,先用 20 棵树把流程跑通,确认精度趋势后再加。
4.4 换数据集后维度对不上
现象是换了一份新数据,预测时报索引越界。原因是model里存的是训练时的特征维度,测试集列数或列顺序变了。随机森林对特征顺序敏感,训练和预测必须用同一套列顺序。解决办法是在入口脚本里固定特征列索引,或者训练前把列名存进model.featureNames,预测时按名字重排。这个坑在二次开发换数据时几乎必踩,提前加个维度断言能省很多调试时间。
5. 二次开发:把教学级实现改成能上手的工具
5.1 三个优先改造点
拿到这份代码想继续用,我一般按“先稳、再快、再好用”的顺序改。第一是给buildTree加最大深度和最小增益阈值,防止深树过拟合,参数暴露到入口脚本里方便调。第二是把阈值遍历换成等频分箱,候选点从“所有唯一值”降到 20 个左右,训练速度能提一个量级,精度损失通常不到一个点。第三是把model存成.mat文件,训练一次反复用,别每次预测都重训。下面是一个带早停和分箱的建树片段:
function node = buildTree(X, y, mtry, depth, maxDepth, minGain) node.prediction = mode(y); node.isLeaf = true; if depth >= maxDepth || numel(unique(y)) == 1 || size(X,1) < 5 return; end % 等频分箱生成候选阈值,替代遍历所有唯一值 candidates = quantile(X(:, featIdx), linspace(0.1, 0.9, 9)); % ... 后续增益计算与递归,分裂增益低于 minGain 时停止 endmaxDepth建议从 10 起调,minGain设成 1e-4 量级,quantile的 9 个分位点对应 10 箱。这几个参数一加,代码从“能跑”变成“敢用在稍大一点的数据上”。
5.2 验证改造是否有效的办法
改完别凭感觉说“好像快了”。固定随机种子rng(42),同一份数据同一组参数跑改造前后,对比三项:测试集精度、训练耗时(tic/toc包住训练段)、OOB 误差。精度掉超过两个点就回退分箱粒度,耗时没降说明瓶颈不在阈值遍历而在别处。我习惯把这三项打成一个表格存下来,改一版存一行,回头能清楚看到哪次改动真正起了作用。随机森林本身有随机性,单次结果不可信,至少跑五折交叉验证取均值再下结论。
5.3 一个具体技巧:用 OOB 做超参搜索
调numTrees和mtry时不用反复切验证集,直接用 OOB 误差当目标。写个双层循环,mtry从sqrt(p)/2扫到2*sqrt(p),numTrees从 50 扫到 300,每组的 OOB 误差记下来取最小。这个技巧的好处是每次训练只跑一遍,评估几乎零成本,比网格搜索加交叉验证快好几倍。注意 OOB 估计在树太少时方差大,numTrees低于 50 的结果别太当真。
从那以后我每次改这类集成学习代码,都强制先固定随机种子、跑一遍基线、把精度和耗时记下来再动手,不然改到最后根本说不清是哪一步起了作用。希望这份拆解帮到你,拿到源码先跑通入口脚本,再按建树、预测、评估的顺序逐个函数读,改起来心里就有底了。
本文还有配套的精品资源,点击获取