
1. 为什么单模型在多变量时序预测上总是“差一口气”高方差是病根先说一个我这两年反复遇到的场景。别人拿一套多变量时序数据来找我说想预测未来一周的某个指标原始数据给得很全温度、湿度、风速、上游流量全都有单看每一条曲线都挺像回事。然后他先跑了一个决策树回归训练集拟合得很漂亮R方能干到0.95以上。一到测试集结果曲线抖得像心电图今天预测对了明天开始狂偏后天又突然拉回来。然后他得出的结论是“这数据不行”“模型不适合做时序预测”。但问题不在数据也不在决策树这个模型本身问题在于他用了一个高方差模型还只跑了一次。所谓高方差简单说就是模型对训练集的具体样本非常敏感。决策树的分裂规则是不断找最优切分点训练数据只要换一小段树的结构可能就完全变了。这在独立同分布的横截面数据上还能靠交叉验证兜底但在时间序列上特别致命因为时间序列样本之间本来就有自相关你拿前80%做训练后20%做测试树把训练集里的局部噪声都记下来了预测时遇到一点新的噪声模式就崩。BaggingBootstrap Aggregating解决的就是这个方差问题。思路一句话就能说完既然一棵树不稳定那我抽很多份自助样本每份样本训练一棵树最后把所有树的预测结果平均。每棵树单独看仍然不稳定但它们的误差会互相抵消平均之后方差显著降低。这个原理跟“不要把鸡蛋放在同一个篮子里”是一回事只不过在统计上可以严格证明如果单模型的方差是σ²那B个模型的平均方差理论上能降到σ²/B前提是各模型误差相关性不高。把这套逻辑搬到多变量时序预测上正好对症下药。多变量时序数据通常变量维度不高但每个变量的滞后项可以构造出很多特征树模型擅长在这些特征里找非线性交互关系而Bagging负责把“换一段数据就变一套分裂规则”的不稳定性压下来。这也是为什么在Matlab里你给TreeBagger这种集成模型喂多变量时序数据十次里有八次比单棵决策树或者单纯的线性AR模型效果更稳。我手边最常见的落地场景是负荷预测和气象驱动的指标预测。这类数据有一个共同特点影响因素多变量之间关系复杂既有周期性又有随机扰动而且很难写成一个显式方程。线性模型拟合不来非线性段单棵决策树拟合了但过度敏感支持向量回归调参调到怀疑人生。Bagging在准确率和稳定性之间给了一个非常均衡的答案而且Matlab实现非常顺手不需要像Python里那样额外装一堆包。下面我把整个流程拆开讲从数据窗口构造、Matlab代码实现到训练验证、踩坑记录都会说明白。代码给的是可以直接跑通的水准你换成自己的数据时只需要改读文件和变量名。2. 建模前的重头戏多变量时序的滑窗构造与切分方式2.1 把时间序列变成监督学习样本滑窗和滞后的选择逻辑很多人拿到多变量时序数据第一反应是直接开一个回归模型输入是当前时刻的所有变量输出是目标变量的未来值。这样做不是不行但你浪费了时间序列最宝贵的信息——历史。时序预测模型的本质是希望模型从“已知的历史模式”推断“未知的未来走势”。这意味着输入不能只用当前时刻的观测值还要把过去若干时刻的观测值一起作为特征。这就是滑窗sliding window存在的意义把一条时间序列切成很多个固定长度的小段每段从时间t往前的所有历史数据作为输入特征第thh是预测步长的目标值作为输出标签。用一个具体例子说明。假设你有一个三变量时间序列每个时刻包含温度、风速、负荷三个值你的目标是预测未来一小时负荷。如果选择滞后2阶那就是用t时刻、t-1时刻、t-2时刻的温度、风速、负荷共9个特征去预测t1时刻的负荷。滑窗每向前滑动一步就产生一条新样本。这样一条长度为T的序列大约能构造T-maxLag-h条样本。滑窗宽度也就是最大滞后阶数直接影响模型能看到的“记忆长度”。滞后太少模型看不到足够的历史模式预测效果会明显变差滞后太多特征维度爆炸样本量不变但特征数翻倍对树模型来说计算量上涨而且会引入大量不重要的噪声特征。我一般会先用偏自相关函数PACF看目标变量自身显著滞后的范围再结合业务周期定一个初始值比如单日周期数据至少取到24小时周周期数据取到168小时然后压缩到合理范围。2.2 各变量滞后阶数为什么不能一刀切这里要重点说一个新手经常踩的坑所有输入变量用同一个滞后阶数。多变量时序预测里每个变量对目标变量的影响节奏不同。仍然用负荷预测举例温度对负荷的影响往往有延迟炎热天气的制冷负荷可能在下午两三点达到峰值但温度传感器记录到的峰值可能出现在下午一点中间差了一两个小时。气压变化对负荷的影响延迟可能更长。如果用同一个滞后阶数构造特征等于强行让所有变量以相同的“时间视角”看历史显然不合理。我的经验做法是每个变量单独定滞后阶数而不是一刀切。具体操作是先对每个变量和目标变量做相关分析看一下不同滞后阶数下相关系数在哪个区间最显著。比如遍历滞后1到48小时找到温度滞后3小时相关系数最高湿度滞后1小时最高目标变量自身滞后2小时最高那就按这个组合去构造样本窗。这个工作量并不大手动做一遍也就几分钟但效果非常明显。我原来跑一个风速影响下的污染物浓度预测模型时按统一滞后12小时建模验证集的RMSE一直在某个水平上不去改成各变量单独定滞后阶数之后RMSE下降了大约12%。原因是某些衰减快的变量被强行拖长了记忆窗口引入的全是无关噪声。2.3 时序数据不能乱切训练/验证/测试的划分规则横截面数据做机器学习时通常的做法是随机打乱数据再切训练集、验证集、测试集。时序数据绝对不能这么干因为你一旦打乱就人为破坏了时间顺序性导致模型“偷看未来”训练和测试之间的独立性也不存在了。正确的切分方式是按时间顺序切前70%作为训练集紧接着的10%作为验证集用于调参最后20%作为测试集用于最终评估。这里有一个细节容易被忽略如果训练集和验证集之间没有留缓冲段紧挨着切分那么验证集开头的样本特征里包含了训练集末尾的真实值预测结果会异常好给你一种“模型很牛”的错觉。实际原因是时序数据的连续惯性下一时刻的值本来就大概率接近上一时刻如果验证集开头沿用了训练集末尾的信息预测等于开卷考试。解决这个问题的办法是刻意设置一个gap。我的习惯是在训练集和验证集之间空出与最大滞后阶数等长的时间段验证集和测试集之间也一样。留出来的这段数据不参与训练也不参与评估就纯粹作为一个缓冲区保证数据在信息层面互不重叠。比如窗口最大滞后是24小时那切分的时候就在之间去掉24个小时的数据。还有一个更严谨的做法叫滚动时间窗验证固定一个训练窗口长度每次向后滚动一个时段重新训练并预测效果更接近真实业务环境但计算开销大。简单项目用静态切分加gap就够用了。3. Matlab里的两条落地路线TreeBagger实战与自写Bootstrap3.1 最省事的写法TreeBagger直接回归Matlab官方自带TreeBagger这个函数本质上就是随机森林的实现而随机森林在特征全部参与分裂时就是标准Bagging。如果你用TreeBagger把特征选择比例设为all那它就等价于纯粹的Bagging决策树。TreeBagger的基本参数有三个必须理解清楚Method设置为regression告诉函数你要做回归而不是分类。NumTrees决策树数量默认值偏小建议自己设置。NumPredictorsToSample每次分裂时随机选择的特征数量。设为all意味着每次分裂都考虑全部特征这就是标准Bagging设为一个小于总特征数的值就变成真正的随机森林。如果你想先跑标准Bagging就设all。调用代码长这样mdl TreeBagger(100, XTrain, YTrain, ... Method, regression, ... MinLeafSize, 5, ... NumPredictorsToSample, all, ... OOBPrediction, on, ... Options, statset(UseParallel, true));这里有个容易忽略的选项OOBPrediction,on开启后系统会在训练过程中记录袋外样本的预测误差。做Bagging时这个OOB误差非常有价值它可以让你在不额外划分验证集的情况下估计模型大概的泛化误差还能画出误差随树数量变化的曲线用来判断设置多少棵树才够。3.2 完整可运行的Matlab训练与评估脚本直接给一份能跑通的代码数据部分我先生成模拟数据方便你直接复制运行。换成真实数据时只需要把读文件那几行的数据源换掉即可。% MainBaggingTimeSeries.m % 多变量时序预测Bagging集成决策树 Matlab实现 clear; clc; close all; rng(2024); %% 1. 生成模拟多变量时序数据 % 实际使用时替换成 xlsread / readmatrix / csvread 读入你自己的数据 T 1500; t (1:T); X1 5 * sin(2 * pi * t / 50) 0.5 * randn(T, 1); X2 3 * cos(2 * pi * t / 23) 0.002 * t 0.2 * randn(T, 1); Y 0.6 * X1 0.3 * X2 sin(2 * pi * t / 140) 0.8 * randn(T, 1); data [X1, X2]; target Y; %% 2. 构造滑窗样本各变量单独定滞后阶数 nLagVec [12, 6]; % 变量1滞后12阶变量2滞后6阶 predHorizon 1; % 预测未来第1步 [XTrain, YTrain, XVal, YVal, XTest, YTest] ... splitWindow(data, target, nLagVec, predHorizon, 0.7, 0.1); %% 3. 训练Bagging模型TreeBagger全特征 标准Bagging ntrees 100; mdl TreeBagger(ntrees, XTrain, YTrain, ... Method, regression, ... MinLeafSize, 5, ... NumPredictorsToSample, all, ... OOBPrediction, on, ... Options, statset(UseParallel, true)); %% 4. OOB误差曲线判断树的数量是否够 oobErr oobError(mdl); figure; plot(oobErr, LineWidth, 1.5); grid on; xlabel(决策树数量); ylabel(OOB误差); %% 5. 测试集预测与评估 YPredTest predict(mdl, XTest); RMSE_test sqrt(mean((YTest - YPredTest).^2)); MAE_test mean(abs(YTest - YPredTest)); MAPE_test mean(abs((YTest - YPredTest) ./ YTest)) * 100; fprintf(测试集 RMSE: %.4f\n, RMSE_test); fprintf(测试集 MAE : %.4f\n, MAE_test); fprintf(测试集 MAPE: %.2f%%\n, MAPE_test); %% 6. 方向准确率 diffReal diff(YTest); diffPred diff(YPredTest); dirAcc mean(sign(diffReal) sign(diffPred)); fprintf(方向准确率: %.2f%%\n, dirAcc * 100); %% 7. 绘制预测对比图 figure; plot(YTest, b-, LineWidth, 1.2); hold on; plot(YPredTest, r--, LineWidth, 1.2); legend(真实值, Bagging预测值); xlabel(时间步); ylabel(目标值); grid on; title(Bagging多变量时序预测结果对比);这里拆分的splitWindow函数我单独写一下逻辑是把原始时序转成样本矩阵并完成按时间顺序的切分和gap设置function [XTrain, YTrain, XVal, YVal, XTest, YTest] ... splitWindow(data, target, nLagVec, predHorizon, ratioTr, ratioVa) % data: N x p 输入变量矩阵 % target: N x 1 目标序列 % nLagVec: 1 x p 每个变量的滞后阶数 % predHorizon: 预测未来第几步 T length(target); p size(data, 2); maxLag max(nLagVec); % 构造滑窗 nFeat sum(nLagVec); nSamples T - maxLag - predHorizon 1; X zeros(nSamples, nFeat); Y zeros(nSamples, 1); idx 0; for tt maxLag : T - predHorizon idx idx 1; feat []; for j 1:p for lag 1:nLagVec(j) feat(end1) data(tt - lag 1, j); end end X(idx, :) feat; Y(idx) target(tt predHorizon); end nTr floor(nSamples * ratioTr); nVa floor(nSamples * ratioVa); gap maxLag; % 留gap防止信息跨集泄漏 idxTr 1:nTr; idxVa (nTr gap 1) : (nTr gap nVa); idxTe (nTr gap nVa gap 1) : nSamples; XTrain X(idxTr, :); YTrain Y(idxTr); XVal X(idxVa, :); YVal Y(idxVa); XTest X(idxTe, :); YTest Y(idxTe); end注意这里我把gap设成了maxLag也就是最大滞后阶数这样能保证训练、验证、测试三段的滑窗样本在时间信息上有真正的缓冲区。如果你的数据有自然周期比如日周期数据gap建议再拉长到至少一个周期长度。3.3 想彻底掌控流程自己写Bootstrap循环平均TreeBagger虽然方便但有时你会有定制需求。比如我希望每棵树的权重不一样、我想比较均值和中位数聚合的效果、我需要把训练好的每棵树单独保存用于增量预测。这些用TreeBagger的封装接口反而不灵活自己写Bootstrap平均是最直接的。核心逻辑就三步第一步从训练集中有放回地抽取一个和原训练集等大的自助样本记录没被抽到的样本作为袋外样本第二步在自助样本上训练一棵决策树用fitrtree即可第三步重复B次后把每棵树在测试集上的预测取平均。下面给一个自写版的核心代码骨架B 50; nTrain size(XTrain, 1); nTest size(XTest, 1); predMat zeros(nTest, B); for b 1:B idxBoot randsample(nTrain, nTrain, true); % 有放回抽样 tree fitrtree(XTrain(idxBoot, :), YTrain(idxBoot), ... MinLeafSize, 5); predMat(:, b) predict(tree, XTest); % 可选保存每棵树用于后续增量预测 % trees{b} tree; end YPredBoot mean(predMat, 2);如果你还想试试中位数聚合只需要把最后一行改成median(predMat, 2)。在实际长尾分布数据里中位数聚合往往比均值更稳因为均值会被个别极端预测值带偏。我对比过TreeBagger和自写Bootstrap在相同数据上的效果结果非常接近TreeBagger因为内部做了很多工程优化速度会快一些。自写的优势在于流程透明调试方便尤其适合教学演示和需要完全控制算法的场景。3.4 变量重要性不要迷信时序外生变量的坑TreeBagger自带一个变量重要性输出用法是mdl.OOBPermutedPredictorImportance通过打乱每个特征计算OOB误差增幅来衡量重要性。这个方法在很多介绍性文章里被奉为标准流程但放在多变量时序预测里它的结果非常容易误导你。原因在于时序特征之间普遍存在滞后相关性。假设温度滞后1小时、滞后2小时、滞后3小时都是重要特征这三个特征本身高度相关。打乱其中一个时另外两个还能提供相近信息OOB误差上升不明显导致系统判定该特征“不重要”。再比如目标变量自身的历史滞后项和某个外生变量的滞后项相关性很强重要性排名也会被扭曲。替代方案更直观逐个剔除某个变量的所有滞后特征重新训练一个模型并比较验证集误差。误差上升越明显说明这个变量的信息越不可替代。代价是每个变量都要重训一次模型计算量大一些但结果可靠得多。对于特征数不超过十几个的多变量时序数据这个计算成本完全可接受。4. 实测中踩过的坑和调优心得让Bagging时序预测真正好用4.1 数据预处理里的三个隐形陷阱第一个陷阱是归一化参数的“泄漏”。决策树模型本身不需要特征归一化因为分裂规则基于排序而不依赖量纲。但你如果做了归一化必须用训练集的统计量去变换验证集和测试集。我见过有人直接对整个序列做min-max归一化再切分看起来一切正常但测试集的min和max信息已经被模型间接“看到”了评估结果偏乐观。这类泄漏在树模型上影响相对小但属于统计不严谨养成坏习惯以后换到神经网络模型时就会吃大亏。第二个陷阱是NaN值处理的破坏性。很多人读入数据后直接用rmmissing把含NaN的行删掉。对横截面数据这没问题但对时间序列来说删掉一行等于删掉一个时间点破坏了时刻之间的连续性。更麻烦的是如果某一行因为传感器故障缺失删除后可能把原本相隔几分钟的两个时刻拼在一起引入一个虚假的时间跳跃。我的建议是先用fillmissing做填充优先用线性插值或前向填充。树模型对插值引入的误差鲁棒性不错所以不需要太担心填充方式影响过大。真正的底线是不要破坏时间序列的顺序结构。第三个陷阱是训练/验证集紧邻造成的“惯性假象”。前面在切分部分提到过这里再强调一次因为这是最隐蔽的。时序数据的自相关系数通常很高上一时刻的值和下一时刻的值非常接近。如果验证集和训练集只隔一个样本模型几乎可以用“复制粘贴”策略拿到不错的分数但这个分数毫无意义因为你真正用模型时预测的是完全未知的未来不是昨天的延续。我每次评估都会先做一个朴素基线用上一时刻的观测值直接作为预测值算出基线误差再对比Bagging模型的误差。如果你的模型连这个朴素基线都超不过说明学到的东西还很有限。4.2 评估指标别只盯RMSE加上方向准确率和基线对比RMSE和MAPE是时序预测最常用的两个指标但它们各有缺陷。RMSE因为平方项的存在对极端峰值非常敏感。时序数据里传感器偶尔跳变带来的几个大误差可能让RMSE变得很难看而模型在大多数时刻的表现其实不错。MAPE对量级敏感如果目标值本身接近零一点点误差就会导致百分比爆表。两个指标一起看能减少单一指标带来的错觉。我习惯再加一个方向准确率Directional Accuracy公式很简单真实序列相邻时刻的变化方向和预测序列相邻时刻的变化方向一致的比例。对很多决策场景来说方向比绝对数值更重要比如预测明天是涨还是跌方向对了才有操作价值。代码在上一节脚本里已经给了就是一个sign(diff)的比较。另一个必须做的是基线对比。我推荐至少跑两个基线朴素预测上一时刻值作为下一时刻预测和线性模型多变量线性回归。如果Bagging模型在这两个基线面前都不占优势问题很大概率出在数据构造或窗口设计上而不是模型选择上。找到问题之后再回头调特征和窗口比一味增加树的个数有用得多。4.3 参数怎么调bag数量、叶子数、并行训练的经验值Bagging模型的参数不多一个决策树数量B一个最小叶子节点数MinLeafSize外加一个特征采样比例NumPredictorsToSample。B的取值建议先用OOB误差曲线定。训练时开启OOBPrediction,on训练完画oobError曲线观察误差随树数量增加是否趋于平稳。一般到50棵左右曲线就明显平坦100棵足够。继续加到几百棵收益很小反而增加预测耗时。MinLeafSize直接控制每棵树的分段粒度。设得太小每棵树的树结构复杂单棵树过拟合风险高尽管Bagging能部分抵消但计算量上升设得太大模型过于粗糙欠拟合。时间序列数据的经验值通常在3到10之间。我自己的做法是跑一个小网格搜索试1、3、5、10、20以验证集RMSE为准选最小的。NumPredictorsToSample设all时模型就是标准Bagging。如果你发现特征维度比较多比如超过20个可以试一下设成总特征数的三分之一或平方根等于把Bagging进阶到随机森林。随机森林的特点是在分裂时只随机挑选一部分特征参与竞争能进一步降低树与树之间的相关性对特征相关性强的数据有明显效果。做时序预测遇到高维特征时我经常在Bagging和随机森林之间切换对比并不固定用哪一种。并行计算方面TreeBagger支持设置Options, statset(UseParallel, true)在数据量大的时候能明显节省训练时间。但要注意多核训练结果有一定随机性因为自助抽样本身就是随机的所以模型每次运行会有细微差异这是正常现象。要保证可复现就在开头固定rng。4.4 多步预测怎么扩展递归预测和直接策略的取舍上面例子只做了单步预测也就是预测未来第1步。实际业务里很多人要预测未来24小时、未来一周这就涉及多步预测策略。最省事的做法是递归预测把第一步的预测值当作已知历史接进滑窗再预测第二步循环下去。代码改动很小但误差会逐级累积预测越远越不可靠。尤其是在高自相关但信噪比低的数据上几步之后预测值会向均值回归曲线明显被拉平。另一种是直接策略为每个预测步长单独训练一个模型第k步的模型用滑窗直接输出第k步预测。好处是每一步都有独立的训练模型不存在误差累积坏处是训练k个模型计算成本线性上升而且你没有用到前序预测的信息。我自己的取舍标准是看预测步长和数据噪声水平。预测步长在5步以内直接策略和递归策略差距不大超过10步我通常直接策略配合多输出模型。如果特征维度本身不大多训几个Bagging模型的计算成本其实可控这也是Bagging对比神经网络的一个天然优势——每次训练都是抽样加平均并行起来很轻松不用担心梯度消失或者训练不稳定。4.5 顺手试出来的几个变体思路最后分享一下我在Bagging基础上顺手做过的几个变体不算标准方法但实测有些场景效果不错。第一个是把预测目标也纳入输入特征矩阵做滞后而不是只把外生变量滞后。目标变量的历史值本身就是最强预测特征把它的滞后项和其他变量一起构造窗口模型能同时利用自身的周期惯性和其他变量的干预信息效果普遍比只用外生变量好。第二个是混合Bagging和线性趋势项。决策树是分段常数对目标数据里明显的线性趋势拟合能力有限。如果你在数据里看到一个长期缓慢上升或下降的趋势可以先把趋势项用线性回归拟合出来然后对残差做Bagging预测最后把趋势预测值和残差预测值叠加。这个做法帮我处理过一个风速和污染物浓度联合预测的项目处理趋势之后测试集MAPE下降了将近20%。第三个是Bagging集成多个不同模型的预测结果。比如你同时训练决策树、线性回归、一个简单的LSTM每个模型分别给出测试集预测然后对三个预测做加权平均。权重可以根据验证集表现来定。这种集成思路和Bagging底层逻辑一脉相承——多个弱模型的平均总比单个模型稳。加权的目的不是追求每个模型都准而是让它们在不同区域互补。我自己的体会是Bagging这套框架好在它非常朴素不需要复杂的分布式系统不需要高配GPU一个Matlab脚本就能跑起来适合快速验证想法。你在实际项目里用到它时精力分配大概应该是数据清洗和窗口构造占一半时间模型训练和调参占三成剩下两成留给评估和基线对比。这些步骤都做扎实了你会发现单模型预测翻车的问题其实大部分都能被Bagging稳稳兜住。