时间序列预测这块儿,单模型的稳定性问题一直让人头疼。我前段时间跑了一批销售数据预测任务,单模型怎么调都有点虚——训练集拟合得很漂亮,换到验证集就整体滞后一拍,甚至换个时间区间直接翻车。后来把预测器换成Bagging集成模型,效果明显稳了一个档次。今天就用一个可以直接在Matlab里跑起来的例子,把基于Bagging集成模型做时间序列预测这件事说透:从原理、基学习器选择、特征构造,到TreeBagger和手写Bagging两套代码,再到调参和踩坑记录,一步一步走完。如果你已经有Matlab基础、用过或听说过随机森林/Bagging,想把它真正落地到时间序列预测上,这篇内容应该能帮你少走不少弯路。
1. 先厘清方案:Bagging为什么适合时间序列预测
1.1 单模型准确但不稳定,这才是真问题
很多人一上来就追求"更准的模型",但实际业务里真正让人头疼的往往是"不稳定"。同一套模型,数据稍微变一点,预测结果就大幅波动。拿时间序列来说,单棵回归树或者单个神经网络,对训练数据末端的敏感度非常高。训练样本里最后几个点的观测值稍微偏离一点,整条预测曲线的走向都可能被带偏。
这不是模型没用,而是单模型的方差太高。评分、调参、换结构,折腾半天,可能只是把这几个点的过拟合问题压下去,换个区间又冒出来。所以我后来倾向于先稳定基础,再追求精度——Bagging就是用来做这件事的。
1.2 Bagging通过什么机制稳住预测
Bagging全称是Bootstrap Aggregating,核心就两步:一是对原始训练集做有放回随机抽样,生成B个不同的训练子集;二是在每个子集上训练一个基模型,再用平均(回归)或投票(分类)把B个模型的预测结果合并起来。
这里面有个非常直接的方差解释。假设B个模型的预测误差方差都是σ²,两两之间的相关系数为ρ,那Bagging后平均预测的方差近似等于:
[ \rho \cdot \sigma^2 + \frac{1-\rho}{B} \cdot \sigma^2 ]
从这个式子能看出来两件事。第一,当B变大时,(\frac{1-\rho}{B})这一项会趋向于0,但(\rho \cdot \sigma^2)这一项是消不掉的,所以树的数量加到一定程度收益就饱和了;第二,真正决定Bagging效果上限的,是基模型之间的相关性ρ。基模型之间的差异越大,ρ越小,集成后的方差就越低。这也解释了为什么基学习器通常选高方差、低偏差的模型——回归树就是典型,它们单打独斗容易过拟合,但互相"纠错"之后反而能发挥出很好的稳定性。
1.3 时间序列的特殊点在哪
处理时间序列时有个绕不开的问题:样本不是独立同分布。标准Bootstrap默认每个样本独立,直接硬套会破坏掉数据中的时间顺序依赖。但在滑窗监督学习的框架下,这个问题其实没那么可怕。
我们通常把时间序列按滞后阶数改造成特征矩阵,每一行代表"用前lag个点预测下一个点"。此时行与行之间虽然存在重叠,但每行内部的时间先后关系已经被编码进特征里了。对有放回抽样而言,抽样的是"样本行",不是时间点本身,所以每行内部的结构不会被破坏。只有在做递归多步预测、需要把预测值回填到输入里的时候,才需要特别注意误差累积的问题,这部分在后面讲到多步预测时会详细展开。
提示:如果序列存在很强的长期相关性,或者你直接对原始时间点做Bootstrap而不是对滑窗样本行做Bootstrap,那就应该考虑分块Bootstrap(Block Bootstrap)的方案。后面我会给一个分块索引的代码片段。
2. 核心环节拆解:基学习器与特征工程
2.1 为什么基学习器首选回归树
Bagging对基学习器是有要求的:高方差、低偏差、对数据扰动敏感。最典型的就是决策树。原因很直接:
- 决策树对数据变化非常敏感,换一批样本树结构就完全不同,这正好为集成提供了多样性。
- 单棵树的偏差不算高,如果加了剪枝约束,偏差会稍微上升,但换来的是泛化能力提升。
- 训练速度快,非线性拟合能力强,不需要像神经网络那样调一堆超参数。
- Matlab里
fitrtree和TreeBagger原生支持,几乎零成本上手。
至于为什么不建议用单层神经网络当基学习器,我个人的体会是:训练时间更长,随机初始化带来的不确定性很大,而且小样本下网络表达能力过剩,容易过拟合。复杂度高,收益却不明显,没必要。如果你想做更激进的对比,可以考虑在TreeBagger基础上加特征随机抽样(也就是随机森林),但纯Bagging已经能满足大部分时间序列预测场景。
2.2 滑窗怎么把时间序列变成监督学习
时间序列预测本质上是个序列问题,但大多数机器学习模型只能处理"特征到标签"的映射关系。所以第一步是把原始序列(y_1, y_2, \dots, y_n)转换成监督学习格式:对第(i)个样本,特征向量是([y_{i}, y_{i+1}, \dots, y_{i+lag-1}]),标签是(y_{i+lag})。
这里最关键的参数就是滞后阶数lag。lag决定了模型能"回看"多长的历史信息。lag太小,模型看不到周期性;lag太大,特征维度膨胀,训练变慢,还容易引入噪声。我的做法是先画一下自相关图(autocorr(y))观察显著滞后的位置,再分别试几个候选值对比验证集误差。
2.3 三个关键技术参数
手动实现Bagging时,有三个参数必须弄明白:
- B(树的数量):理论上方差随B增大而下降,但收益递减。一般50~200足够,超过300几乎没差别,只会增加训练时间。
- 有放回抽样比例:经典Bagging对每个子集抽样数量与原始训练集样本数一致,是100%有放回抽样。Matlab的
TreeBagger里对应InBagFraction,默认值是1.0,但实际使用中调成0.8~0.9,可以在多样性和训练样本量之间做个折中。 - MinLeafSize(叶子节点最小样本数):它控制树的深度,越小树越深、方差越大,越大树越浅、方差越小。Bagging本来就靠高方差基模型来发挥效果,所以MinLeafSize不用调太大,一般1~5都合理;如果发现单棵树过拟合太严重,再适当增大。
另外一个容易被忽略的点是:Ng = lag(特征数量),在TreeBagger里不额外抽样时,每棵树用全部特征。如果你把NumPredictorsToSample设成lag,那就是随机森林;设成小于lag的值,相当于在Bagging基础上加了特征随机化,进一步增大基模型差异。这个可以根据实际效果尝试。
3. 实操:完整Matlab代码走一遍
3.1 数据准备与滑窗特征构造
先用一组带趋势、周期和噪声的合成数据来演示。这段代码的温度、销量、用电负荷都能用同样方式替换成你自己的y向量。
%% 生成示例时间序列 rng(42); t = (1:400)'; trend = 0.01 * t; % 线性趋势 season = 5 * sin(2*pi*t/30) + 3*cos(2*pi*t/15); % 双周期成分 noise = randn(400,1) * 0.5; % 噪声 y = trend + season + noise; %% 滑窗构造监督学习样本 lag = 6; X = zeros(length(y)-lag, lag); Y = zeros(length(y)-lag, 1); for i = 1:length(y)-lag X(i,:) = y(i:i+lag-1)'; Y(i) = y(i+lag); end %% 划分训练集和测试集(按时间顺序,不能用随机划分) trainRatio = 0.8; nTrain = floor(trainRatio * size(X,1)); Xtrain = X(1:nTrain, :); Ytrain = Y(1:nTrain); Xtest = X(nTrain+1:end, :); Ytest = Y(nTrain+1:end);这里有个很重要的细节:时间序列的划分绝不能随机打乱,必须严格按照时间先后顺序。前面的样本负责训练,后面的样本负责验证,否则训练集里混入未来信息,测试误差会严重失真。
3.2 最快路径:直接用TreeBagger
Matlab的Stats and Machine Learning Toolbox里自带TreeBagger,这就是一个现成的Bagging封装。不想手动写循环的话,直接调它就行。
%% 使用TreeBagger实现Bagging B = 100; bag = TreeBagger(B, Xtrain, Ytrain, ... 'Method', 'regression', ... 'InBagFraction', 0.8, ... 'MinLeafSize', 5); %% 预测 YpredBag = predict(bag, Xtest); YpredBag = cell2mat(YpredBag); % predict返回的是cell数组,需要转换注意predict返回的是cell类型,回归任务里每个元素是字符串形式的数值,必须用str2double或cell2mat转换。这个坑我刚开始也踩过。
3.3 手动写循环,搞清楚Bagging内部逻辑
TreeBagger封装得很省事,但如果不懂内部循环,出了问题很难排查。下面这份手写代码和TreeBagger本质是一样的,便于你理解每一步在干什么。
%% 手写Bagging实现 B = 100; models = cell(B, 1); for b = 1:B % 有放回抽样,样本数与原始训练集一致 idx = randsample(size(Xtrain,1), size(Xtrain,1), true); % 在抽样子集上训练回归树 models{b} = fitrtree(Xtrain(idx,:), Ytrain(idx), ... 'MinLeafSize', 5); end %% 集成预测:平均所有树的输出 YpredManual = zeros(size(Xtest,1), 1); for b = 1:B YpredManual = YpredManual + predict(models{b}, Xtest); end YpredManual = YpredManual / B;关键就一行:randsample(..., true),这个true表示有放回抽样。很多初学版本的错误都出在这里——写成无放回,训练子集就和原始数据集几乎一致,树的差异没了,Bagging退化成单棵树的重复训练,效果自然不行。
如果你需要分块Bootstrap来保留时间序列局部相关性,可以用下面这个索引生成函数,替换掉上面的randsample:
function idx = blockBootstrapIndices(n, blockLen) % 生成一组分块Bootstrap索引,块长blockLen,可重叠 numBlocks = ceil(n / blockLen); startPool = 1:(n-blockLen+1); starts = randsample(startPool, numBlocks, true); temp = []; for s = starts temp = [temp, s:s+blockLen-1]; %#ok<AGROW> end idx = temp(1:n)'; end分块Bootstrap适合那些局部相关性特别强、滑窗特征不足以表达完整依赖的序列。实际操作中,块长一般取lag值的2~3倍,或者根据自相关图衰减速度来定。
3.4 预测效果评估与可视化
模型训完必须量化评估,不能光看曲线"好像拟合上了"。我常用的三个指标是RMSE、MAE和MAPE,公式和代码都很直观:
%% 评估指标 rmse = sqrt(mean((YpredBag - Ytest).^2)); mae = mean(abs(YpredBag - Ytest)); mape = mean(abs(YpredBag - Ytest) ./ abs(Ytest)) * 100; fprintf('RMSE: %.4f\nMAE : %.4f\nMAPE: %.2f%%\n', rmse, mae, mape); %% 可视化 figure; plot(Ytest, 'b-', 'LineWidth', 1.2); hold on; plot(YpredBag, 'r--', 'LineWidth', 1.2); grid on; legend('实际值', 'Bagging预测值', 'Location', 'best'); xlabel('测试样本序号'); ylabel('数值'); title('Bagging时间序列预测效果');以我这个示例数据为例,单棵回归树测试集RMSE通常在0.6左右波动,换成B=100的Bagging后能稳定在0.35~0.45之间。MAPE也会从3%~4%降到1.5%~2%。集成模型很少在某个测试集上惊艳地"完美拟合",但它能让你每批数据都跑出可接受的稳定结果,这在业务上比偶尔一次惊艳更重要。
3.5 多步递归预测的实际处理
前面演示的是一步预测。如果要做未来H步的预测,工程上最常用的方式是递归预测:先把当前窗口的预测值算出来,然后把窗口往前推进一格,把新预测值拼到历史序列末尾作为下一步的输入。
%% 多步递归预测示例:预测未来H步 H = 20; Xcur = Xtest(1, :)'; % 从测试集第一行开始滚动 YpredRec = zeros(H, 1); for s = 1:H % 当前窗口下所有树的预测 p = zeros(B, 1); for b = 1:B p(b) = predict(models{b}, Xcur'); end YpredRec(s) = mean(p); % 滚动窗口:去掉最老的点,加入最新预测值 Xcur = [Xcur(2:end); YpredRec(s)]; end递归预测最大的问题是误差累积。第一步预测偏了0.1,这个0.1会作为输入影响第二步,第二步误差就可能放大到0.3。这也是为什么单模型递归预测经常"越预测越歪"——Bagging通过均值抹平了一部分随机误差,会比单模型稳很多,但多步预测的误差累积本质还在,这一点要有心理预期。
4. 常见问题排查与避坑实录
4.1 我踩过、也见别人踩过的几个坑
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 预测结果在个别点剧烈跳变 | 树数量太少 | B调到100以上,方差公式里((1-\rho)/B)虽然会变小,但少了一两棵树影响很明显 |
| 训练集误差很低、测试集误差高 | MinLeafSize太小导致过拟合 | 增大MinLeafSize到10~20,稍微牺牲单树拟合换取泛化 |
| 预测曲线整体平移、滞后一拍 | lag太小,模型看不到足够历史 | 用autocorr(y)观察自相关,增大lag;也可以加入时间序号和周期编码特征 |
| 结果每次运行都不一样 | 没有固定随机种子 | 代码开头加rng(42),TreeBagger训练前也保持相同随机状态 |
| 训练速度太慢 | 树太多或树太深 | 减小B,增大MinLeafSize;TreeBagger支持并行选项:statset('UseParallel', true) |
| predict返回结果没法直接计算误差 | TreeBagger返回cell类型 | 用str2double(predict(bag, Xtest))或cell2mat转换 |
4.2 正则化和并行计算的正确姿势
有人觉得加正则化会让单棵树变弱,导致Bagging整体效果下降。实际经验是:适当增大MinLeafSize,可以让随机抽样带来的扰动更明显、树之间的相关性更低。从方差公式看,ρ下降带来的收益比σ²略微上升带来的损失要大得多。所以别一上来就追求"每棵树都要准",而要追求"每棵树都不太一样且不太离谱"。
TreeBagger默认串行训练,B=200棵回归树在几分钟内能跑完,但如果你交叉验证要跑几十次,并行就很有必要:
options = statset('UseParallel', true); bag = TreeBagger(B, Xtrain, Ytrain, ... 'Method', 'regression', ... 'MinLeafSize', 5, ... 'Options', options);并行池会在训练时自动启动,完成后记得delete(gcp('nocreate'))释放资源。
4.3 调参顺序和快速验证方法
我给一个自己常用的调参顺序,基本不用网格搜索也能找到实用配置:
- 固定B=50,先确定lag。画出不同lag下的验证集RMSE曲线,选拐点位置。
- 固定lag,把B分别设置为30、50、100、200,观察RMSE下降幅度。如果B从100加到200RMSE只降了不到1%,那就用100。
- 然后调MinLeafSize,从1、3、5、10试试。调到5~10通常已经够用,再往大容易让曲线过于平滑。
- 最后如果数据量不大(比如几千行),可以用5折时间序列交叉验证(注意按时间顺序滚动划分)确认配置稳定性。
这套流程我在实际项目里用下来,通常半天内就能稳定收敛到一套满意配置,效率远高于盲目跑Bayesian Optimization。
4.4 一个小技巧:把预测结果的残差留下来
Bagging训练结束后,不要只盯着预测值。把训练集上的拟合残差(Ytrain - \hat{Y}train)保留下来,画一下自相关图。如果残差还有明显的周期或趋势成分,说明滑窗特征没彻底捕捉到序列结构,可以考虑增加lag或者加入外部特征。如果是白噪声形态,那Bagging这部分基本做到位了。我很多次都是靠这一步发现"原来不是模型问题,是特征没构造够"。
5. 我的几点体会和扩展方向
在实际项目里用下来,我最大的感受是:Bagging不提升单棵树的预测上限,但把预测下限抬得很高。单棵树可能在某段数据上表现惊艳,也可能在某段数据上崩得离谱;Bagging极少给你惊喜,但也极少让你惊吓。对生产环境、对业务汇报来说,稳定可解释的结果比撞大运的精度更值钱。
后续想再进一步的话,有几个顺手的方向可以试:一是把Bagging换成随机森林,在特征维度上再做随机抽样,通常能再降一点误差;二是对残差做一次轻量级模型(比如AR多项式回归)修正,等于把Bagging当作特征提取器;三是把基学习器从回归树换成fitrensemble或者带早停的梯度提升,做成Bagging和Boosting的对比,选更合适的模型。我最终落地的几个预测系统里,经常同时跑Bagging和LightGBM,取两者预测结果的加权平均,稳定性比单用任何一个都更放心。你可以先把这篇里的代码跑通,再按自己的数据特征做扩展,路径会很清晰。