首页 / 资讯中心 / 文章详情

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

两百行MATLAB实现随机森林:从基尼分裂到袋外评估全解析 ★ FEATURED ARTICLE
简介面向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返回 modelrfPredict.m预测入口model, XNew返回类别向量rfOOB.m袋外精度评估model, X, y返回 accbuildTree.m递归建树当前样本、候选特征、深度返回 nodefindBestSplit.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 为 truelabel 为具体类别内部节点上 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(g1)) / 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)); endfunction 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 endbootstrapSample 用 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 endtreePredict 对每个样本走一遍从根到叶子的路径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四个核心参数对模型行为的影响各不相同实际调参时我一般按下面这张表来估计方向参数常见取值对模型的影响numTrees50200越多越稳定但训练时间线性增长50 棵之后精度增量很小numFeaturesceil(sqrt(d))越小树间相关性越低但单棵树容量下降minLeaf总样本的 1%5%越小树越深过拟合风险越大maxDepth1525限制树高对高维稀疏特征尤其有效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 现象特征矩阵里有 NaNOOB 精度虚高但测试集崩掉训练过程没有报错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 全部换成 entropyNodeparentGini 改成 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 调参最后才上测试集给结论这套流程我几乎原样搬到了用其他语言实现的项目里。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站