简介:这是一个面向机器学习入门及以上水平研究者、数据分析师和MATLAB用户的随机森林预测项目完整示例。资源基于波士顿房价数据集,系统演示了使用遗传算法、粒子群算法及贝叶斯优化对随机森林回归模型进行超参数寻优的完整流程,涵盖数据划分、适应度函数设计、代码实现、结果验证与未来改进方向等关键环节。包内附有可直接运行的MATLAB代码,有助于理解集成学习与自动化调参机制。资源以1个docx文档形式提供,压缩包大小约32KB,内容集中、便于快速查阅。已有80人学习浏览,适合用于数据科学竞赛备赛、回归类项目实践或作为相关课程实验参考。
1. 为什么选随机森林做 MATLAB 预测项目:三行命令之外的完整交付链
做预测项目的人,最容易卡住的往往不是模型,而是数据根本喂不进去。之前我接过一批来自某工厂的设备振动数据,特征列几十个,目标是要预测未来一周的故障概率。当时把随机森林模型放到 MATLAB 里跑,本以为一个训练函数就能结束,结果从缺失值清洗、类别变量编码到验证集划分,连着一个周末都在处理边界问题。后来我把这套流程沉淀下来,发现用随机森林模型优化的完整 MATLAB 预测项目,真正值钱的部分并不是几十行训练代码,而是程序与数据如何组织、参数如何取舍、验证怎么做才算数。这篇笔记的读者,是手里有表格数据、想把预测真正交付出去的工程师和研究生。你能得到的不是一句“调 fitrensemble 就行”,而是一条从原始 CSV 到干净训练集、再到可解释模型的完整路径,外加我踩过的几个坑。
2. 数据先喂对:从原始表格到随机森林可以吃的输入
2.1 清洗顺序:缺失值、离群值和变量类型的处理习惯
随机森林不需要特征归一化,因为树模型按阈值做分裂,特征数值的绝对大小不影响分裂结果。这是它在 MATLAB 预测项目里比神经网络省事的地方,但也带来两个前提:特征列不能满是缺失值,类别变量必须有正确的类型。如果直接把一个字符串列当成数值传给训练函数,MATLAB 的报错信息会让人怀疑人生。
我一般先读数据、看概览,再决定清洗方案。用readtable读入 CSV 后,第一件事是看每一列的类型和缺失情况:
% 读取原始 CSV,并快速查看类型与缺失情况 T = readtable('sensor_data.csv'); T.Properties.VariableNames % 打印所有列名 summary(T) % 每列类型、缺失数量、取值分布readtable会自动判断列类型,但经常把时间戳读成datetime、把类别列读成字符串元胞数组。summary能直接输出每列的缺失值数量和取值分布,清洗前必须先跑这一步,否则后面会反复报错。
确认缺失情况后,我按变量类型分别填充。数值列我习惯用中位数而不是均值,中位数对离群值更稳;类别列缺了就直接补一个'missing'水平,或者删除这个样本:
% 统计每列缺失数量 missCount = sum(ismissing(T), 1); for i = 1:width(T) if isnumeric(T.(i)) && missCount(i) > 0 medVal = median(T.(i), 'omitnan'); % 用中位数填充数值列 T.(i) = fillmissing(T.(i), 'constant', medVal); elseif iscategorical(T.(i)) && missCount(i) > 0 % 类别列若有未定义的空水平,先清理掉 T.(i) = removecats(T.(i)); end end这段代码里median(..., 'omitnan')很关键,它让中位数计算忽略掉已有的缺失值,避免中位数本身变成 NaN。fillmissing用常量填充,而不是用前后值插值,因为表格数据没有天然的先后顺序。这里要提醒一点:填充用的统计量应该只来自训练集,测试集要做同样填充但必须沿用训练集算好的值。这个原则第 5 章的避坑部分还会专门展开。
离群值处理要更克制。树模型对离群值比线性模型稳健,过度删除反而会丢失样本。我只对特征列处理,不对目标列动手,而且阈值非常保守:
% 离群值标记后置为 NaN,交给上一轮中位数填充 for i = 1:width(T) if isnumeric(T.(i)) && i ~= targetCol flag = isoutlier(T.(i), 'percentiles', [1 99]); if sum(flag) < 0.01 * height(T) % 离群比例不超过 1% 才处理 T.(flag, i) = NaN; end end end这里用percentiles而不是默认的中位数绝对偏差,因为 [1 99] 分位数更符合工程直觉:只有最极端的 1% 才算离群。处理后的 NaN 会被上一段代码用中位数覆盖,所以整个清洗流程的顺序是:标记离群 -> 置 NaN -> 统一填充。
2.2 类别变量编码:直接指定 CategoricalPredictors 而不是盲目转数值
随机森林处理类别变量不需要像线性模型那样做 one-hot,树分裂时可以直接按类别子集二分。在 MATLAB 中,正确做法是把列转成categorical,然后在训练时用CategoricalPredictors告诉训练函数哪些列是类别。如果硬把类别转成 1、2、3 这样的整数当数值用,树会错误地把类别当成有序连续量,分裂出来的节点毫无业务含义,这是新手最容易翻车的地方。
读入的表格里,类别列可能是字符串元胞数组或字符矩阵,先统一转换:
% 把字符串/字符型类别列统一转成 categorical T.shift = categorical(T.shift); % 班次:早班/中班/晚班 T.group = categorical(T.group); % 设备分组 T.sensor = categorical(T.sensor); % 传感器型号 % 检查每个类别变量的水平数 for i = 1:width(T) if iscategorical(T.(i)) fprintf('%s : %d levels\n', T.Properties.VariableNames{i}, ... numel(categories(T.(i)))); end endcategorical自带一组离散水平,树分裂时会尝试把所有水平分成两个子集,水平越多枚举次数越多。如果某个类别列有上千个水平,训练时间会指数级上升,还容易生成只覆盖单条样本的叶节点。我的警戒线是:水平数超过样本数的 10% 就要处理。比如训练集有 5000 条,某列超过 500 个水平,就该考虑删除或合并。
合并低频水平我常用mergecats:
% 统计水平频次,保留 top 20,其余合并成 one 个新水平 tbl = table(categories(T.group)', countcats(T.group)', ... 'VariableNames', {'level', 'count'}); tbl = sortrows(tbl, 'count', 'descend'); keepLevels = tbl.level(1:20); dropLevels = tbl.level(21:end); T.group = mergecats(T.group, dropLevels, 'other');这段代码把出现次数最低的那些水平统一合并成'other',训练时模型只会见到 21 个水平(20 个高频水平加'other')。这样既保留了主要信息,又给未来可能出现的冷门水平留了去处。注意mergecats的第三参数是目标新水平名,dropLevels会被整体合并到'other'里。
2.3 划分数据集:先划分后清洗,用 cvpartition 固定训练/测试边界
很多人在数据清洗完成后才做训练/测试划分,这是流程上的大坑。我的习惯是先把样本分成训练集和测试集,再对训练集做清洗统计。测试集从划分那一刻起就彻底隔离,后面所有统计量都不能再碰它。
rng(2024); % 固定随机数种子,让划分结果可复现 cv = cvpartition(height(T), 'Holdout', 0.3); trainIdx = training(cv); testIdx = test(cv); Ttrain = T(trainIdx, :); Ttest = T(testIdx, :);Holdout比例 0.3 意味着 30% 样本不参与训练,中小数据集这个比例够用;数据量大时可以下调到 0.2。cvpartition的好处是划分本身自带随机种子控制,同一段代码在任何机器上跑出的划分结果一致。这里的training(cv)和test(cv)返回的是逻辑索引,直接用它们切表。
划分完成后,前面 2.1 的填充逻辑要改成只从训练集取统计量:
% 用训练集的中位数填充测试集同列,避免数据泄漏 for i = 1:width(Ttrain) if isnumeric(Ttrain.(i)) medVal = median(Ttrain.(i), 'omitnan'); Ttrain.(i) = fillmissing(Ttrain.(i), 'constant', medVal); Ttest.(i) = fillmissing(Ttest.(i), 'constant', medVal); end end测试集填充时用的medVal来自训练集,测试集自身的中位数没有被使用。这个细节直接决定交叉验证分数是否可信。测试集在你调完所有参数之前,应该一次都不碰。
3. 训练随机森林的 MATLAB 写法:入口函数选型与三个必调参数
3.1 三种建模入口怎么选:fitrensemble、TreeBagger 与单棵树
MATLAB 里做随机森林常见有三个入口,选错会直接影响后续工作流。我按使用频率排如下:
| 入口函数 | 回归/分类 | 特点 | 适用场景 |
|---|---|---|---|
fitrensemble/fitcensemble | 两者都支持 | 支持表格输入、内置交叉验证、自带 OOB 指标,配合templateTree可精确控制树结构 | 首选,适合绝大多数预测项目 |
TreeBagger | 两者都支持 | 面向对象风格,oobError和oobPermutedPredictorDeltaError调用直观,但要求输入全为数值 | 接手旧代码或需要逐指标检查时 |
fitrtree/fitctree | 两者都支持 | 单棵决策树,训练极快,便于检查单特征分裂效果 | 调templateTree参数时快速试错 |
fitrensemble里要显式写'Method', 'Bag',这表示用 bootstrap 聚合的方式训练。严格说,随机森林和袋装树的区别在于每棵树分裂时还要随机抽特征子集,所以在templateTree里设置NumVariablesToSample后,这套组合才符合教科书里的随机森林定义。我在项目里都是按这个标准配置来写的。
3.2 树数量、叶节点和特征抽样:三个必调参数及其顺序
三个参数决定随机森林的绝大部分行为:
| 参数 | 含义 | 参考范围 | 调参优先级 |
|---|---|---|---|
NumLearningCycles | 集成中树的数量 | 回归 200~500,分类 100~300 | 第一优先 |
MinLeafSize | 叶节点最少样本数 | 回归从 5 试起,分类从 2 试起 | 最后调 |
NumVariablesToSample | 每棵树分裂时随机候选特征数 | 回归约 p/3,分类约 sqrt(p),p 为特征总数 | 第二优先 |
调参顺序有讲究:先确认树数量够不够,再看特征抽样,最后调叶节点。树数量通常最先固定,因为 OOB 误差曲线能直接告诉我们 100 棵还是 300 棵够用;特征抽样控制树与树之间的相关性,相关性太高则集成退化成单棵大树的平均;叶节点大小是正则化手段,放在最后处理是因为它和过拟合之间的平衡最依赖数据本身的噪声水平。
判断树数量是否够用,我一般画 OOB 累积误差曲线:
% 训练一个不带交叉验证的袋装回归集合,用于观察 OOB 收敛 mdl = fitrensemble(Ttrain(:, predictors), Ttrain.target, ... 'Method', 'Bag', ... 'NumLearningCycles', 300, ... 'Learners', templateTree('MinLeafSize', 5, ... 'NumVariablesToSample', floor(p/3))); oobCurve = oobLoss(mdl, 'Mode', 'cumulative'); plot(1:300, oobCurve);oobLoss的cumulative模式返回从第 1 棵树到当前树的袋外误差,曲线先快速下降然后进入平台期。如果曲线在 250 棵时还在明显下降,说明树不够;如果 50 棵以后就平了,加到 300 棵只是浪费时间。这里的p是特征总数,floor(p/3)是回归任务常用的特征抽样数。
3.3 最小可运行代码:交叉验证与独立测试集预测
准备好Ttrain、Ttest和特征名列表后,最小可运行的回归代码如下:
% 特征列表和类别特征索引 p = numel(predictors); catIdx = find(contains(string(predictors), {'shift', 'group'})); rng(42); % 固定种子,训练过程可复现 mdl = fitrensemble(Ttrain(:, predictors), Ttrain.target, ... 'Method', 'Bag', ... 'NumLearningCycles', 200, ... 'Learners', templateTree('MinLeafSize', 5, ... 'NumVariablesToSample', floor(p/3)), ... 'KFold', 5, ... 'CategoricalPredictors', catIdx); cvLoss = kfoldLoss(mdl); % 五折交叉验证的均方误差 predCV = kfoldPredict(mdl); % 每一折袋外样本的预测值KFold设为 5,训练函数内部会做五折划分,每折轮流留出 20% 做验证。kfoldLoss返回的是均方误差,kfoldPredict给出每个样本在所在折外的预测结果。注意:带KFold的对象是交叉验证对象,不能直接拿去做独立测试集预测。最终上线前要用相同参数重训一个完整模型:
mdlFinal = fitrensemble(Ttrain(:, predictors), Ttrain.target, ... 'Method', 'Bag', ... 'NumLearningCycles', 200, ... 'Learners', templateTree('MinLeafSize', 5, ... 'NumVariablesToSample', floor(p/3)), ... 'CategoricalPredictors', catIdx); predTest = predict(mdlFinal, Ttest(:, predictors));这里有个容易漏的步骤:交叉验证对象评估的是“这个参数下的模型大概什么水平”,mdlFinal才是真正预测用的模型。两者参数必须完全一致,否则交叉验证分数和线上表现对不上。分类任务把fitrensemble换成fitcensemble,目标列换成分类标签即可,其余写法相同。
4. 评估与解释:随机森林不是黑匣子,OOB 与特征重要性要会用
4.1 回归指标怎么算:RMSE、MAE 和 R² 要跟基准对比
训练完成后,第一步不是看准确率,而是算清楚模型在交叉验证上的误差。回归任务我固定算三个指标:RMSE、MAE、R²。代码如下:
Y = Ttrain.target; predCV = kfoldPredict(mdl); rmse = sqrt(mean((Y - predCV).^2)); mae = mean(abs(Y - predCV)); ssRes = sum((Y - predCV).^2); ssTot = sum((Y - mean(Y)).^2); r2 = 1 - ssRes / ssTot; fprintf('RMSE=%.3f MAE=%.3f R2=%.3f\n', rmse, mae, r2);RMSE 对大误差敏感,预测里偶尔出现离谱偏差时 RMSE 会明显变大;MAE 更直观,表示平均偏离多少单位。R² 越接近 1 说明模型比“用均值预测”好得越多,但这个指标单独看会骗人。我每次都额外算一个基准模型:
% 基准模型:用训练集均值预测全部交叉验证样本 basePred = ones(size(predCV)) * mean(Y); baseRmse = sqrt(mean((Y - basePred).^2));把baseRmse和rmse放在一起看,能快速判断模型有没有真正在学习。如果 RMSE 只比均值基线低 5%,说明模型没捕捉到有效规律,再调参意义不大;如果低 30% 以上,基本可以继续往下做。这个对比在向业务方汇报时也很有说服力。
4.2 特征重要性与偏依赖:定位模型靠什么做预测
随机森林最容易给人“黑匣子”的错觉,但 MATLAB 里两个工具能让它变得可解释:特征重要性和偏依赖图。
特征重要性我用oobPermutedPredictorDeltaError,而不是简单的predictorImportance。它的原理是把某一列随机打乱,观察袋外误差上升多少,上升越多说明模型越依赖这个特征:
imp = oobPermutedPredictorDeltaError(mdlFinal); bar(imp); set(gca, 'XTickLabel', predictors, 'XTickLabelRotation', 45);这个指标直接和预测误差挂钩,比基于节点分裂增益的重要性更贴近业务判断。注意:两个强相关特征会分摊重要性,比如温度和压力高度相关时,两者重要性都会被压平,不代表它们没用。看到重要性分布明显不合理,要想到相关性和特征冗余,而不要急着删特征。
偏依赖图是给业务方讲模型时最好用的工具。它显示当某个特征取不同值时,模型预测的平均变化趋势:
figure; plotPartialDependence(mdlFinal, predictors{1});比如观察振动特征从 50 升到 80,预测故障概率是否单调上升;如果趋势和业务常识相反,就要回头查特征构造或者数据泄漏。plotPartialDependence也支持传两个特征的索引,画出二维交互热力图,用来发现“温度和压力同时高时故障率激增”这类非线性关系。
5. 随机森林预测避坑:五个我在 MATLAB 里踩过的坑
5.1 预测值像复读机,全在均值附近
现象:模型跑完,对测试集预测出来的值几乎围绕训练集均值波动,画出曲线是几条横线,交叉验证 RMSE 比基准好不了多少。
原因:常见有两个。一是树的数量太少,默认设置下只有十几棵树,每棵树之间的差异又被很大的NumVariablesToSample抹平,集成后预测趋向平均;二是MinLeafSize设得太大,叶节点输出太粗,整棵树的预测粒度已经跟不上目标的波动。
解决:先把树数量提高到 300 以上,画 OOB 曲线确认收敛;然后把NumVariablesToSample从 p/3 往 sqrt(p) 方向调小,增加树间随机性;最后把MinLeafSize从默认值往 2~5 方向调小。每次只动一个参数,记录一次损失。这个现象很容易被误判成特征没选好,实际是集成度不够。
5.2 类别变量水平太多:报错或者内存爆炸
现象:某列有几百上千个水平,用dummyvar一展开,内存直接爆掉;即使没爆,训练时间也涨到让人无法接受。
原因:把设备编号、单号、时间戳这类几乎每条记录都不重复的列当成普通类别特征塞进去了。树模型虽然能处理类别,但水平越多,每次分裂时枚举子集的开销越大。
解决:先把纯编号类列删掉;业务上真正有关的类别列,水平数超过样本数 10% 就合并低频水平。常见做法是保留 top 20 个水平,其余并入 other:
% 统计水平频次,保留 top 20,其余合并为 other tbl = table(categories(T.group)', countcats(T.group)', ... 'VariableNames', {'level', 'count'}); tbl = sortrows(tbl, 'count', 'descend'); keepLevels = tbl.level(1:20); dropLevels = tbl.level(21:end); T.group = mergecats(T.group, dropLevels, 'other');这里mergecats把所有低频水平合并成一个'other',训练时模型只会见到 21 个水平。保留一个'other'还有一个好处:等模型部署后如果遇到新的冷门类别,可以直接归入'other'而不至于报错。
5.3 交叉验证分数虚高:多半是数据泄漏
现象:交叉验证 RMSE 很漂亮,可一上线用测试集实测,误差直接翻倍。
原因:数据泄漏。最常见的是先用全体数据做缺失值填充、离群值剔除或特征选择,再做训练/测试划分。填充用的全体均值已经包含了测试集的信息,模型等于在考试前偷看了答案。
解决:把“划分”提到所有统计步骤之前。缺失值只用训练集统计量填充,离群值阈值只在训练集上判断,特征选择只用训练集。测试集从划分那一刻起就彻底隔离。还有一个容易忽略的点:如果项目里同时做了降维、PCA 这类转换,同样只能用训练集拟合转换器,再套到测试集上。树模型里这些操作不常见,但一旦用了,泄漏路径一模一样。
5.4 特征重要性第一名是时间戳:删掉原始时间列
现象:oobPermutedPredictorDeltaError排序结果里,最靠前的竟然是一个时间戳列,模型预测几乎完全依赖时间顺序。
原因:把datetime当成数值传进树模型,或者把精确到秒的时间戳作为特征。树会按时间戳数值切出很多只覆盖单条样本的叶节点,训练时误差极低,一旦时间分布变了,模型立刻失效。
解决:删除原始时间戳列,按业务需要构造时间衍生特征,比如小时、星期、是否节假日、距上一次事件的间隔。这样的特征既保留周期性,又不会让树把时间当连续数值去硬切:
tt = T.time_col; % datetime 列 T.hourOfDay = hour(tt); % 小时 T.dayOfWeek = weekday(tt); % 星期几 T = removevars(T, 'time_col'); % 删除原始时间戳如果是真正的时间序列预测,还要额外注意样本顺序:先用sortrows(T, 'time_col')按时间排序,再按时间顺序划分训练和测试,不能用随机划分。
5.5 加载保存的模型后预测报错:类别水平不一致
现象:训练好的模型用save保存,换到现场用load加载后做predict,报错说类别水平对不上,或者预测结果和原来不一致。
原因:模型里记录了训练集见过的所有类别水平集合,现场数据里出现了训练时没见过的水平,predict无法处理。另一个原因是保存了整个大对象,包含训练数组等冗余信息,体积几百 MB,加载慢,容易让人误以为文件损坏。
解决:训练前把低频类别合并成'other',保证水平集合稳定;保存前用compact压缩模型;预测脚本里对现场数据做同样的水平对齐:
mdlFinal = compact(mdlFinal); save('rf_pred_model.mat', 'mdlFinal', 'predictors', 'catIdx', 'keepLevels'); % 现场加载后,重新编码类别列 load('rf_pred_model.mat'); Tnew.group = categorical(Tnew.group, keepLevels); Tnew.group(isundefined(Tnew.group)) = 'other'; % 未知名归入 other pred = predict(mdlFinal, Tnew(:, predictors));categorical(x, keepLevels)会把不在keepLevels里的值变成undefined,然后手动赋值为'other'。前提是训练时确实保留了这个水平,keepLevels必须包含'other',否则赋值会失败。
6. 收尾的优化技巧:小网格搜索、模型压缩与结果复现
6.1 网格搜索:固定树数量,一次只搜两个参数
随机森林的超参组合数不小,但真正值得搜的只有MinLeafSize和NumVariablesToSample。我习惯把树数量固定为 300,然后用小网格搜索这两个参数:
minLeafCandidates = [2 5 10]; numVarCandidates = [ceil(sqrt(p)), floor(p/3)]; best = struct('loss', inf, 'minLeaf', [], 'numVar', [], 'mdl', []); for minLeaf = minLeafCandidates for numVar = numVarCandidates cvMdl = fitrensemble(Ttrain(:, predictors), Ttrain.target, ... 'Method', 'Bag', 'NumLearningCycles', 300, ... 'Learners', templateTree('MinLeafSize', minLeaf, ... 'NumVariablesToSample', numVar), ... 'KFold', 5, 'CategoricalPredictors', catIdx); loss = kfoldLoss(cvMdl); if loss < best.loss best.loss = loss; best.minLeaf = minLeaf; best.numVar = numVar; end end end六个组合在中小数据集上几分钟就能跑完。不要一上来就五个参数全搜,组合数量爆炸式增长,而且小数据集上的“最优”换一批数据很容易失效。数据量大时可以考虑用bayesopt做贝叶斯优化,但中小项目手工网格已经够用。
6.2 模型压缩与随机种子:交付现场最容易被忽略的两件事
最终模型训练好后,务必compact再保存:
finalMdl = fitrensemble(Ttrain(:, predictors), Ttrain.target, ... 'Method', 'Bag', 'NumLearningCycles', 300, ... 'Learners', templateTree('MinLeafSize', best.minLeaf, ... 'NumVariablesToSample', best.numVar), ... 'CategoricalPredictors', catIdx); finalMdl = compact(finalMdl); save('rf_model.mat', 'finalMdl', 'predictors', 'catIdx', '-v7.3');compact只保留预测所需的结构,体积通常能缩到十分之一以内;-v7.3是为大数组准备的存储格式,模型较大时能避免保存失败。复现方面,训练前用rng(2024)固定随机种子,并且不要用并行池训练同一个模型,否则每棵树的随机数流不一致,再次运行结果会对不上。
我现在交付随机森林项目的习惯是:先跑一遍这个小网格,把best参数、训练数据、最终模型和 OOB 曲线存档,然后连同特征重要性图、偏依赖图一起交给现场。模型本身不是黑匣子,OOB 收敛曲线、特征重要性和偏依赖图三样东西拿出去,业务方基本能信服。至于那些“预测值全在均值附近”“特征重要性第一名是时间戳”的坑,多数是因为数据和验证流程不干净,而不是随机森林这个算法不够强。希望这篇笔记能帮你在 MATLAB 上少走几趟弯路,把时间花在特征理解和业务沟通上,希望帮到你。
本文还有配套的精品资源,点击获取