做回归预测的人,多少都被“特征太多”折腾过。手里一份多维数据集,特征几十个上百个,直接扔给BP神经网络,经常出现两种结果:要么训练半天收敛不动,要么训练集拟合得漂亮,测试集一测就拉胯。问题往往不在网络结构,而在输入里混进了太多无关特征和噪声。把随机森林递归特征消除(RF-RFE)当作特征筛选器,先把真正有用的特征挑出来,再交给BP神经网络做回归预测,是一个在MATLAB里非常顺手的组合方案。这篇文章就把这套流程从原理到完整代码拆开讲,给出能直接跑通的实现逻辑和调参细节。
这套RF-RFE-BP流程适合谁?简单说:你的目标是连续值回归预测,特征维度在十几到几百之间,并且怀疑特征里掺了不少“水货”。比如风电功率预测、房价估值、工业软测量、信用评分这一类场景。特征极少(五六个以内)用不上它,样本量小到随机森林都训练不动时也先别用它。下面从设计思路讲起。
1. 为什么非要把RF-RFE和BP组合在一起
1.1 高维输入让BP吃尽了苦头
BP神经网络本身不排斥多输入,但它对输入维度和高噪声容忍度有限。为什么?因为BP的参数量直接跟输入维度挂钩。假设输入层有100个特征,隐含层10个节点,光是输入层到隐含层的权值就有1000个,再加隐含层到输出层的权值和偏置,总参数量轻轻松松1000以上。要估计这么多个参数,不仅需要足够多的训练样本,还要保证样本里的信息质量够高。
更麻烦的是高维空间带来的“维度诅咒”。样本量不变时,维度越高,样本在高维空间里越稀疏,模型能稳住的区域就越小。噪声特征还会在反向传播过程中持续贡献随机梯度干扰,表现出来就是Loss下降特别慢,或者直接掉进一个很差的局部最优。我拿一份80维的数据跑过,不筛特征直接训BP,训练集R²能到0.9,测试集掉到0.6左右,典型的过拟合加泛化崩塌。这不是训练次数不够,是输入里该去掉的东西没去掉。
1.2 随机森林在RFE里扮演的角色有多特别
递归特征消除RFE不是随机森林专属算法,它的通用套路是:用一个模型反复评估当前特征子集,每一轮剔除最不重要的特征,在剩余特征上重新训练,循环到目标特征数为止。所以这个“评估模型”的选择很关键。用线性回归做RFE,只能感知线性关系;用SVM做RFE,特征多时训练成本高;用随机森林做排序器,优势刚好互补了随机森林的短板。
随机森林对非线性关系敏感,能捕捉特征之间的交互效应,同时它自带OOB(Out-of-Bag,袋外)验证机制,不用额外划分数据就能得到误差估计。特征重要性度量在随机森林里也比较稳,不像单棵树那样容易受微小扰动影响。和相关系数筛选、互信息筛选这类只看单变量与目标关系的方案相比,RF-RFE会考虑“这个特征在已经有其他特征的情况下还能提供多少增量信息”。这是它最独特的价值。
1.3 这套组合真正适合什么场景
RF-RFE-BP这套组合,我把它定位成中高维连续值回归任务的“预处理+预测”两段式方案。特征维度过高、噪声比重不小、目标又是连续值时,效果尤其明显。我在风电功率预测类数据上试过,原始特征和工况、气象、历史功率都有关,筛完后留下的特征子集往往还能讲出行业故事,可解释性也保留住了。
但也要泼盆冷水:特征已经很少时,RFE派不上太多用场;数据量小到随机森林训练都不稳时,重要性排序会抖动,这时候需要先扩充样本或做更保守的特征筛选。一句话,这套组合解决的是“特征太多太杂导致BP发挥不出能力”的问题,不是万能药。
2. RF-RFE-BP的核心原理,一次串起来讲
2.1 RFE到底是怎么“递归”的
递归特征消除的“递归”两个字,是理解整个算法的钥匙。它不是一次排序就定终生,而是动态排序。过程是这样的:
第一轮,用全部特征训练一个随机森林,得到每个特征的重要性分数,把最不重要的那个特征剔除。第二轮,在剩余特征上重新训练随机森林,再算一次重要性,再剔除当前子集里最不重要的那个。不断重复,直到特征数量降到预设目标。
为什么非要重新训练?因为特征之间存在交互。一个特征单独看可能没用,但在某些特征组合里却很重要;反过来,一个单看很重要的特征,也许和其他特征高度冗余,删掉它模型性能几乎不掉。静态的一次性排序捕捉不到这种动态变化,RFE通过每轮重训,让模型带着“当前特征子集”的反馈去做下一轮选择。
每轮剔除一个特征最精细,但特征多时循环次数太长。我习惯在刚开始特征量很大时,每轮剔除2到3个,等特征少于20个再变成每轮1个。至于最终保留几个特征,不要拍脑袋定。更稳的做法是记录每一轮特征子集对应的模型误差,画一条“特征数量-误差”曲线,选在误差拐点附近、特征数又更少的位置。
2.2 随机森林特征重要性的两种口径
随机森林给特征打分的思路主要有两类。
一类是基于杂质的减少量。在回归树中,每个分裂节点会带来均方误差(MSE)的下降,把所有节点上该特征带来的MSE下降量按树求和,就是该特征的得分。这种计算速度快,但偏袒数值型的大范围特征。
另一类叫OOB排列重要性(Permutation Importance),也是MATLAB中TreeBagger默认提供的思路。它的直觉特别朴素:把某一列特征的值随机打乱,破坏它和目标之间的真实关联,然后看模型误差变大多少。如果打乱这个特征后,模型误差明显上涨,说明这个特征对预测有真贡献;误差几乎不动,说明它就是个可有可无的角色。
回归场景中,我更偏爱排列重要性。因为它不依赖某一棵树内部的分裂过程,更贴近特征本身的边际作用,遇到特征间强相关时也比Gini方式稳健。MATLAB里TreeBagger返回的OOBPermutedPredictorDeltaError字段就是这个指标,后面代码部分会看到。
2.3 BP神经网络的回归机制和关键参数
BP神经网络做回归,结构上通常用三件事:输入层接收特征,隐含层做非线性映射,输出层输出连续值。隐含层激活函数常用tansig(双曲正切S型),输出层激活函数用purelin(线性),这样输出范围不受限,适合回归任务。
理论上,一个足够宽的隐含层就能逼近任意连续函数,这被称为万能逼近定理。实际中隐含层节点数要拿捏好:太少了拟合不了复杂关系,太多了容易死记硬背训练集。网上流传的经验公式有sqrt(输入特征数+输出节点数)+某个常数,也有(输入维数+输出维数)/2的。这些公式只能当起点,务必跑几组对比。
训练算法也有讲究。样本量中等时,Levenberg-Marquardt(MATLAB里对应trainlm)收敛快,精确度高,是默认首选;小样本想要更好泛化,可以试trainbr,它带贝叶斯正则化,能自动约束权值大小;数据量大或内存紧张时,trainscg这类共轭梯度法更省资源。这个选型在后面第4节展开。
3. MATLAB实操:从数据到结果的完整闭环
3.1 环境、数据、评估指标准备
跑这套流程需要MATLAB里装好两个工具箱:Statistics and Machine Learning Toolbox提供TreeBagger和相关统计函数,Deep Learning Toolbox提供feedforwardnet等神经网络函数。版本方面,TreeBagger是老牌接口,兼容性较好,新一些的版本也能正常使用。
数据假设你已经准备成一个MATLAB可读矩阵:X是n行m列的特征矩阵,每行一个样本,每列一个特征;Y是n行1列的目标向量。我习惯把数据和标签分开存放,这样划分训练集测试集时逻辑清晰。实际操作时,可以用load('yourdata.mat')导入,也可以从Excel读入后拆分数组。
评估指标我必看四个:决定系数R²、均方根误差RMSE、平均绝对误差MAE、平均绝对百分比误差MAPE。R²越接近1,说明模型解释了大部分目标变化;RMSE和MAE越小越好;MAPE方便你以百分比视角衡量相对误差。它们对应的计算公式并不复杂,第3.5节代码里直接算给你看。
3.2 数据划分和归一化处理
拿到数据第一步不是训练,而是划分数据集和归一化。这里有个新手常踩的坑:归一化必须在划分之后做,并且要用训练集算出来的参数去处理测试集,绝不能把训练集测试集混在一起归一化。否则测试集的信息在训练阶段就被模型“偷看”了,最后成绩虚高,一上真实数据就露馅。
MATLAB里mapminmax是按行操作的,所以要把特征矩阵转置再处理,让每一行对应一个特征。下面给出数据准备的完整代码:
rng(42); % 固定随机种子,保证实验可复现 % 假设 X: n×m, Y: n×1 n = size(X, 1); trainIdx = randperm(n, round(0.8 * n)); % 80%训练样本下标 testIdx = setdiff(1:n, trainIdx); % 20%测试样本下标 X_train = X(trainIdx, :); Y_train = Y(trainIdx); X_test = X(testIdx, :); Y_test = Y(testIdx); % 用训练集参数做归一化:特征各行归一化到 [0,1] [X_train_norm, ps_X] = mapminmax(X_train', 0, 1); [Y_train_norm, ps_Y] = mapminmax(Y_train', 0, 1); % 测试集用同一组参数直接映射 X_test_norm = mapminmax('apply', X_test', ps_X);注意Y_train_norm现在是1×n_train的行向量,后面供给TreeBagger时我会转成列向量。测试集的目标值Y_test不需要归一化,它只用于最后的对比评估。
3.3 RF-RFE特征选择代码实现
现在进入核心环节。我用TreeBagger来训练随机森林回归模型,每一轮算一遍特征重要性,剔除当前子集里最不重要的特征,同时记录OOB误差,方便后面选最优特征数。
% RF-RFE特征选择 numFeatures = size(X_train, 2); % 初始特征总数 currentIdx = 1:numFeatures; % 当前保留的特征编号 featureRank = []; % 被剔除的特征按顺序记录 oobErrors = []; % 每轮OOB误差, 用于选特征数量 targetFeatNum = 5; % 最少保留特征数 stepNum = 2; % 每轮剔除特征个数, 特征多时可设大些 while length(currentIdx) > targetFeatNum % 训练当前特征子集下的随机森林回归模型 rf = TreeBagger(300, X_train_norm(:, currentIdx)', Y_train_norm(:), ... 'Method', 'regression', ... 'OOBPrediction', 'on', ... 'NumPredictorsToSample', 'all', ... 'MinLeafSize', 5); % 记录当前特征数对应的OOB误差 oobErrors(end+1) = rf.Error(end); % 获取特征重要性(排列重要性, 值越大越重要) imp = rf.OOBPermutedPredictorDeltaError; % 找出当前子集中最不重要的stepNum个特征 [~, sortedIdx] = sort(imp, 'ascend'); dropCnt = min(stepNum, length(currentIdx) - targetFeatNum); dropIdx = sortedIdx(1:dropCnt); % 记录并剔除 featureRank = [featureRank, currentIdx(dropIdx)]; currentIdx(dropIdx) = []; end selectedFeatures = sort(currentIdx); % 最终保留的特征编号 fprintf('最终选中的特征编号: %s\n', mat2str(selectedFeatures)); % 画OOB误差随特征数变化曲线 featCounts = numFeatures:-1:(numFeatures - length(oobErrors) + 1); figure; plot(featCounts, oobErrors, '-o', 'LineWidth', 1.5); xlabel('保留特征数量'); ylabel('随机森林OOB误差'); title('RF-RFE特征选择过程中的OOB误差变化'); grid on;这里rf.Error(end)取的是随机森林在整棵树上累计的最终OOB误差。OOBPermutedPredictorDeltaError返回当前子集每个特征的排列重要性,值越大,说明打乱该特征后误差升得越明显,特征就越重要。
实际使用时,我建议你把targetFeatNum设得偏小一点,比如5到8,跑完看oobErrors曲线。如果曲线在特征数20多时已经趋于平稳,那就没必要砍到5个,取一个“误差不高、特征够少”的位置即可。
3.4 BP神经网络训练与预测
特征子集定下来后,先用它从训练集和测试集中抽出对应列,然后构建BP网络。我用feedforwardnet(hiddenNodes)创建单隐含层网络,默认隐含层激活函数是tansig,输出层是purelin,正是回归任务需要的搭配。
% 抽取RFE选出的特征 X_train_sel = X_train_norm(selectedFeatures, :); X_test_sel = X_test_norm(selectedFeatures, :); % 构建BP神经网络 hiddenNodes = 12; % 隐含层节点数, 建议跑多组对比 net = feedforwardnet(hiddenNodes); % 训练算法: Levenberg-Marquardt, 小规模回归收敛快 net.trainFcn = 'trainlm'; % 网络内部的数据划分: 训练集里再分出15%做验证, 用于早停 net.divideFcn = 'dividerand'; net.divideParam.trainRatio = 0.85; net.divideParam.valRatio = 0.15; net.divideParam.testRatio = 0; % 训练参数 net.trainParam.epochs = 800; net.trainParam.goal = 1e-5; net.trainParam.min_grad = 1e-7; net.trainParam.showWindow = false; % 不需要弹窗 % 训练网络 [net, tr] = train(net, X_train_sel, Y_train_norm); % 测试集预测, 再反归一化还原到原始量纲 Y_pred_norm = net(X_test_sel); Y_pred = mapminmax('reverse', Y_pred_norm, ps_Y); Y_pred = Y_pred(:); % 转为列向量这里有个容易被忽略的细节:feedforwardnet内部默认会从你给的数据中按比例再划出训练、验证、测试三份,验证集用来做早停。我用dividerand只分train和val,并把testRatio设成0,测试集的评估完全交给外在的Y_test,避免内部划分把本来要留作最终评估的数据也“用掉”。
3.5 模型评估与可视化输出
训练完成,下一步是算指标、画图。回归预测的老惯例:真实值曲线和预测值曲线放在一起看,比单看数字直观得多。我习惯再补一张误差直方图,看看预测误差是不是集中在零附近、有没有明显偏大的离群样本。
% 计算评估指标 SS_res = sum((Y_test - Y_pred).^2); SS_tot = sum((Y_test - mean(Y_test)).^2); R2 = 1 - SS_res / SS_tot; RMSE = sqrt(mean((Y_test - Y_pred).^2)); MAE = mean(abs(Y_test - Y_pred)); MAPE = mean(abs((Y_test - Y_pred) ./ Y_test)) * 100; fprintf('R2 = %.4f\n', R2); fprintf('RMSE = %.4f\n', RMSE); fprintf('MAE = %.4f\n', MAE); fprintf('MAPE = %.2f%%\n', MAPE); % 真实值与预测值对比 figure; plot(Y_test, 'b-o', 'LineWidth', 1.2); hold on; plot(Y_pred, 'r-*', 'LineWidth', 1.2); legend('真实值', 'RF-RFE-BP预测值'); xlabel('测试样本序号'); ylabel('目标值'); title('测试集预测效果对比'); grid on; % 预测误差分布 figure; histogram(Y_test - Y_pred, 20); xlabel('预测误差'); ylabel('频数'); title('预测误差分布直方图'); grid on;看到R²、RMSE、MAE、MAPE之后,先别急着高兴。顺手把训练集上的预测误差也算一遍,对比训练集和测试集差异。训练集R²远高于测试集,说明过拟合,需要调整隐含层节点数或增加正则化;如果两个集合都很差,可能特征选择环节把关键特征删掉了,回去看oobErrors曲线,适度放宽保留特征数。
4. 调参思路和现场心得
4.1 RF-RFE侧的两个关键旋钮
随机森林的树数量NumTrees是第一个旋钮。300棵是起步值,特征数量多或者样本量大时,可以升到500甚至更高。树太少,特征重要性的方差会变大,特征排名不稳定;树太多,计算时间上去了,收益却递减。我一般先300跑一轮,如果两次重复实验的特征排名差异明显,再往上加。
第二个旋钮是NumPredictorsToSample,也就是每棵树随机抽几个特征做分裂。分类任务常用sqrt(特征数),回归任务用'all'或者max(floor(特征数/3), 1)都行。用'all'时每棵树能看到全部特征,重要性的可比性更强,计算量也更大。我自己的习惯是回归用'all',这样OOB误差更稳定,跑出来的排序也更可信。
MinLeafSize也要留意,默认值偏小容易过拟合。回归任务设为5到10,可以降低单棵树的方差,整体模型更稳。适当调大叶节点最小样本数,特征重要性会更平滑,不会因为个别离群样本把排序带偏。
4.2 BP侧最容易翻车的三个参数
隐含层节点数是个永恒的纠结。我给一个可复现的操作流程:先按sqrt(输入特征数*输出节点数+1)给个初值,然后以这个初值为中心,上下取8、10、12、15、18等一组数,固定其他条件,逐一训练,对比测试集误差。哪个R²高、RMSE低,就选哪个。别省这一步,十有八九你能找到比默认值好一截的节点数。
训练函数的选择也要讲场景。trainlm在小规模回归里收敛快,精度高,但当样本量超过几万时,它要存储近似Hessian矩阵,内存消耗大,可能跑不动。数据量大时换成trainscg或者trainbr。trainbr我特别喜欢在样本量不大的场景用,它自带贝叶斯正则化,能抑制过拟合,代价是训练慢一点。
学习率和动量因子这些,用默认值一般能跑,但可以微调。初始学习率0.01左右对多数归一化后的回归数据是安全的。学习率太大,Loss震荡;太小,收敛慢。观察训练过程中的Loss曲线是个好习惯,如果有明显震荡,把学习率降一半再试。
4.3 识别模型是真正学到还是死记硬背
训练结束后,我最常做的一步不是看R²,而是看“训练R²-测试R²”的差。这个差如果超过0.1,基本可以断定过拟合。这时候优先检查隐含层节点数是不是给得太大了,其次看训练数据量是不是太少。RF-RFE已经帮你把不相关特征删掉了,过拟合还严重的话,大概率还是模型容量问题。
另外一个容易忽略的点:BP对输入特征的数值范围敏感,特征选择后重新训练时,需要重新确认归一化参数是按训练集算的。有的写法是全部数据统一归一化再划分,这在实践里属于信息泄漏,错误地提高了测试集表现。用我第3.2节的流程,先划分、再归一化、测试集用apply方式转换,就能避免这个问题。
5. 常见问题排查实录
5.1 特征排名每次跑出来都不一样
随机森林本身有随机性,如果两次运行特征重要性排序差异很大,说明树的数量不够,或者样本量太小。先固定rng,确保实验可复现;然后把树数量从300加到500或800;再检查样本量,样本只有几十个时,随机森林的OOB误差和特征排序都很不稳,这种情况建议先用方差过滤或相关性分析砍掉一部分特征,再上RF-RFE。
特征之间存在强共线性也会让重要性在高度相关的几个特征之间来回切换。遇到这种情况,不要强行解释单个特征的重要性排序,把重要特征分组成簇来看,相关的几个特征选一个有代表性的留下即可。
5.2 模型成绩虚高,一换数据就露馅
最常见的元凶是归一化泄漏。临床表现是训练集和测试集分离前的统一归一化,导致测试集的统计信息提前进入了训练过程。解决办法就是严格遵守“划分-归一化-apply映射”的顺序。其次是交叉验证中混进了重复样本或者时间上相邻的样本,比如时序数据没有打乱,直接把后段数据当测试集,前段当训练集,模型遇到的往往是比实际更轻松的场景。
5.3 BP训练结果每次都不一样
BP的初始权值是随机的,多次训练结果有波动是正常的,但波动幅度太大就有问题了。先把rng固定,再把net.divideFcn的设置固定,保证每次网络切分的验证集都一样,这样不同实验之间的差异只来自网络初始化和训练过程本身。
波动仍然很大时,检查训练数据和特征选择是否稳定。如果特征子集每次都在变,BP网络自然跟着不稳定。先稳定RFE环节,再谈BP环节。
5.4 测试集R²变成负数
R²是负数说明模型预测效果比“直接用均值当预测值”还差,模型基本废了。排查顺序:第一看是不是反归一化出了问题,mapminmax('reverse', ...)时用错ps_Y,或者方向搞反,预测结果完全偏离量纲;第二看测试集里有没有极端离群样本,个别数值特别大的样本会拉爆整体误差;第三看训练阶段是否已经过拟合到丧失泛化能力,把隐含层节点数调小再试。
我把这些问题整理成一张速查表,方便你直接对着排查:
| 现象 | 优先排查项 | 处理建议 |
|---|---|---|
| 特征排序不稳定 | 树数量不足、样本量小 | 提高NumTrees,固定rng |
| 训练好、测试差 | 过拟合、信息泄漏 | 减小隐含层节点数,重查归一化流程 |
| 多次训练结果波动大 | 随机种子未固定 | 固定rng,固定数据划分 |
| 测试集R²为负 | 反归一化错误、离群样本 | 检查ps_Y使用方式,剔除极端值 |
| 特征选完模型还是差 | 特征数砍得过多 | 看oobErrors曲线,放宽targetFeatNum |
最后再分享一个实战技巧:RFE跑完的特征排序结果其实很值钱,别用完就丢。把每一轮的特征编号、OOB误差、最终选中的特征子集都保存成MAT文件。后面你换BP结构、换训练函数、甚至换一种预测器,都直接用之前选好的特征子集,不用重新跑特征选择。我在实际项目里,光这一项就能省掉大半重复计算时间。
我个人用下来的体会是,特征选择不是越狠越好,留多少个特征要看误差曲线的态度。RF-RFE-BP的价值在于让模型在“干净”的输入上专注拟合真实规律,而不是替你去分辨哪列数据是垃圾。数据质量、业务理解和这个流程配合,预测结果才真正能打。这套流程往后还能继续扩展,比如把RFE里的随机森林换成梯度提升树,把BP换成LSTM,思路完全一样,框架不用推倒重来。