做了快两年时序预测的工程落地,我最大的感受是:只要还在用“固定结构 + 手工调参”的思路,单变量时序预测就永远像开盲盒。最近把一个设备监测项目彻底重构了一遍,核心方案就是标题里写的 BO 贝叶斯优化 + Bayes-Transformer,专做单变量时序预测(单输入单输出),环境是 Matlab。整个项目跑下来,验证集误差比之前用 LSTM 网格搜参降了差不多 20%,而且最关键的提升不是模型结构本身,而是把“调参玄学”变成了可复现的搜索流程。这篇文章我会把网络怎么搭、BO 怎么封进来、数据切分怎么避坑、核心 Matlab 代码段长什么样,全部摊开讲清楚。
如果你手头也是单变量序列,比如传感器振动值、负荷曲线、流量数据,而且你正打算从 LSTM 往 Transformer 迁移,或者只是想把调参从手动猜改成自动搜,那这篇应该能帮你省不少弯路。
1. 为什么我最后选了“BO + Transformer”来做单变量时序预测
1.1 单变量预测的老方法到底卡在哪儿
单变量时序预测的经典解法很多,传统路线有 ARIMA、指数平滑,机器学习路线有 SVR、随机森林,深度学习路线最常见的是 LSTM、GRU。听着选择很多,但实际项目里困难不少。
ARIMA 这类统计模型对线性关系拟合好,可工业现场的大多数单变量序列都带着明显的非线性特征,比如设备振动信号里的突发尖峰、负荷数据里的工作日与节假日双周期、流量数据里的随机噪声叠加趋势漂移。做预测时这些非线性成分一旦占比高,ARIMA 的效果就会明显下滑,不管你怎么调差分阶数和滞后阶数。
LSTM 的好处是能学非线性映射,但它本质上是按时间顺序逐步处理信息的,历史窗口越长,前面那些有效信息被“冲淡”的风险越大。虽然引入了门控机制,实际表现比普通 RNN 好,但在窗口需要覆盖一天甚至一周的场景里,模型对窗口靠前位置的依赖仍然偏弱。更麻烦的是,LSTM 对超参数也很敏感,隐藏单元数、层数、学习率、dropout、序列长度,每一个都直接影响最终精度。
说白了,老方案的共同问题是:线性方法不够用,非线性方法又太依赖人工把参数调到“恰好能用”的状态。
1.2 我对比过 LSTM 和 Transformer 之后的结论
我这次没有一上来就直接上 Transformer,而是先把 LSTM 作为基线模型跑了一遍。对比实验做下来,结论其实挺清晰。
当输入窗口在 48 点左右时,LSTM 和 Transformer 的验证精度差距并不大,有时 LSTM 甚至更好一点。一旦把窗口拉长到 168 点(代表小时级数据的最近一周),Transformer 的优势就出现了。原因在于自注意力机制让任意两个时间点可以直接交互,不需要信息在隐藏状态里一层层传递。比如预测上午 9 点的负荷,模型可以直接关注 7 天前同一天的上午 9 点附近,这种“长程依赖 + 周期性模式”的组合,Transformer 处理起来比 LSTM 自然得多。
Transformer 也不是没有代价,它对超参数的敏感性比 LSTM 还要高。头数、模型维度、编码器层数、dropout、位置编码方式都会影响收敛行为。它就像一个上限很高但下限也很低的模型,用默认参数直接跑,结果可能还不如简单线性回归。
这让我意识到,选 Transformer 的前提是必须把超参数搜索也当成项目的一部分来设计,而不是把调参当成最后的临时动作。
1.3 贝叶斯优化解决的正是 Transformer 的“玄学调参”问题
Transformer 的参数组合空间是连续且高维的:窗口长度是整数、学习率是连续值、层数是个小整数、dropout 是 0 到 1 的实数。网格搜索在这种场景下基本不可行,因为组合数量会随着参数个数指数膨胀;随机搜索虽然能跑,但每次训练完整模型耗时很长,纯粹撞运气效率太低。
贝叶斯优化(BO)的思路不一样。它把“验证集误差”当成一个未知函数,用高斯过程等概率模型去近似这个函数,每次评估完一组超参数后,就更新这个代理模型,然后通过采集函数(比如期望改进 EI)挑出下一组“最有希望”的参数组合。所以 BO 不是在样本空间里均匀撒点,而是在已知低误差区域附近做得更密集,逐步逼近最优区域。
在实际效果上,我用 30 次贝叶斯优化迭代就找到了比之前手工调了一个月还好的参数组合,这在深度学习调参场景里是非常划算的。
有一点要特别说明:标题里的 Bayes-Transformer 并不是“贝叶斯神经网络 + Transformer”那种把权重分布也做随机化的方法,而是“用贝叶斯优化(BO)自动搜索 Transformer 超参数”的工程方案。很多读者一看到 Bayes 就联想到贝叶斯推断,这里需要先澄清。
2. Matlab 里的网络结构:单输入单输出的 Transformer 到底怎么排布
2.1 “单输入单输出”的本质:一个历史窗口进,一个未来值出
很多朋友对“单输入单输出”有误解,以为是一对一映射,也就是用一个历史时刻的数值预测下一个时刻。如果是这种结构,那根本不需要 Transformer,线性回归就够了。
实际项目里所谓的单输入单输出,指的是输入特征维度是单变量,而输入本身是一段历史窗口,输出则是未来某个时刻的一个数值。具体来说,给定当前时刻 t,取过去 L 个观测值作为输入,预测 t+1 时刻的值(单步预测),或者预测 t+H 时刻的值(多步中的一步)。模型结构上就是一个“序列到点”的回归模型。
在构造训练样本时,我使用滑动窗口把一条长序列切成很多样本。比如序列长度是 3000 点,窗口长度是 168,那么单个样本就是这段窗口内的 168 个值,标签是窗口后面那一个点。滑动步长为 1 时,一条 3000 点序列能造出约 2832 个样本,数据量可以得到充分扩充。
输出层的设计也要对应:整个网络最后只输出一个标量,所以回归头就是一个fullyConnectedLayer(1)。
2.2 网络骨架:Embedding、位置编码、自注意力块与回归头
单变量输入的维度很低,每个时间点就只有 1 个数值,不能直接送进多头注意力。架构上我按下面这几步来搭。
第一步是嵌入层:把每个时间点的单值通过一个全连接层映射到 d_model 维的向量,这一步等同于把 1 维特征扩展成更丰富的表达空间。
第二步是位置编码:Transformer 本身没有顺序概念,注意力计算是“集合到集合”的操作,所以必须显式注入时间位置信息。我的做法是给嵌入后的向量加上可学习的位置编码,也就是初始化一个和序列长度、模型维度一致的参数矩阵参与训练。数据量不大时,可学习位置编码比原始论文里的正弦编码更容易收敛。
第三步是编码器块:每个编码器块由多头自注意力、前馈网络、残差连接和层归一化组成。自注意力负责捕捉任意两个时间点之间的关系,前馈网络负责非线性变换,残差连接保证梯度能流畅回传。
第四步是输出头:编码器块输出一个序列,我实验后发现对最后时间步的输出接全连接层,或者对整个序列做池化再回归头,效果差异不大。更稳妥的做法是在所有时间步的输出上做池化,把整段编码信息聚合成一个向量,再接一个输出维数为 1 的全连接层。
Matlab 里如果用的是较新的 Deep Learning Toolbox,可以直接找transformerLayer,它的常用参数就是NumHeads和ModelDimension。如果版本受限,也可以手动用attentionLayer拼装。下面的代码是按手动拼装思路写的,更贴近老版本也能运行的方式:
% 单变量序列输入: L x 1 % 嵌入 + 位置编码 + 多头注意力 + 前馈网络 + 回归头 numHeads = 4; modelDim = 32; layers = [ sequenceInputLayer(1, 'Name', 'input_seq') fullyConnectedLayer(modelDim, 'Name', 'embedding') positionEncodingLayer(modelDim, 'Name', 'pos_encode') % 自定义层 attentionLayer('NumHeads', numHeads, 'Name', 'attention') layerNormalizationLayer('Name', 'ln1') fullyConnectedLayer(modelDim, 'Name', 'ffn') reluLayer('Name', 'relu') fullyConnectedLayer(modelDim, 'Name', 'ffn_proj') additionLayer(2, 'Name', 'residual') % 残差连接 layerNormalizationLayer('Name', 'ln2') fullyConnectedLayer(1, 'Name', 'output') regressionLayer('Name', 'regression') ]; lgraph = layerGraph(layers); lgraph = connectLayers(lgraph, 'embedding', 'residual/in2'); lgraph = connectLayers(lgraph, 'pos_encode', 'attention');这里的positionEncodingLayer需要自己实现,核心就一句话:把可学习的参数按时间步叠加到嵌入向量上。对习惯用trainNetwork的朋友,自定义层可以写成layer的子类,也可以简化成featureInputLayer+ 全连接处理,只要数据格式对齐就行。
2.3 避免 Transformer 在小数据上乱来的几个结构细节
单变量预测的数据量通常不大,一条序列几千个点已经算多的了。在这种数据规模下,Transformer 很容易表现得不稳定,我在实际操作里总结了几个必须注意的结构细节。
模型维度 d_model 不要盲目增大。我见过有人直接把模型维度设成 128 或 256,但单变量序列本身信息量有限,模型维度太高会让中间表示过于稀疏,训练速度变慢,还容易过拟合。按我的经验,d_model 在 16 到 64 范围内更合适,兼顾表达能力和数据规模。
残差连接和层归一化不能省。少了残差连接,深层网络在训练时梯度很难稳定传递;少了层归一化,注意力输出的分布会随着训练波动,导致损失曲线上下乱跳。
dropout 对输出稳定性很重要。Transformer 在小数据上过拟合现象很常见,你会在训练集上看到损损降到很低,但验证集损损反而上升。给注意力层和前馈层加上 dropout,能明显缓解这个问题。
3. 贝叶斯优化的目标函数与超参数搜索空间设计
3.1 把整套训练流程封装成一个“可被最小化的黑盒”
贝叶斯优化的输入是一个目标函数,它接收一组超参数,返回一个标量损失值。对深度学习调参来说,这个目标函数内部要做的事情是:用给定的超参数构建网络、在训练集上完成整个训练过程、在验证集上计算损失,然后返回这个验证损失。
关键是把这些步骤全部封装成一个函数,让bayesopt可以反复调用。我习惯把封装函数写成runTransformerWithParams(params, trainData, valData),其中params是结构体,包含窗口长度、注意力头数、模型维度、dropout、学习率等字段。函数内部第一步要固定随机种子,保证同一组超参数多次评估的结果是一致的,否则贝叶斯优化会噪声太大。
下面这段代码展示了贝叶斯优化目标函数的基本骨架:
function valLoss = runTransformerWithParams(params, Xtrain, Ytrain, Xval, Yval) rng(42); % 固定随机种子,保证可复现 numHeads = params.numHeads; modelDim = params.modelDim; dropout = params.dropout; learnRate = params.learnRate; % 构建网络 lgraph = buildTransformer(numHeads, modelDim, dropout); % 训练选项 options = trainingOptions('adam', ... 'InitialLearnRate', learnRate, ... 'MaxEpochs', 30, ... 'MiniBatchSize', 64, ... 'ValidationData', {Xval, Yval}, ... 'ValidationFrequency', 10, ... 'Verbose', false); % 训练并得到验证 loss net = trainNetwork(Xtrain, Ytrain, lgraph, options); Ypred = predict(net, Xval); valLoss = sqrt(mean((Ypred - Yval).^2)); % RMSE end有一点需要提醒:如果训练过程中损失出现 NaN,这个返回值会污染贝叶斯优化过程。我通常会在函数内部加一个判断,检测到 NaN 或 Inf 时直接返回一个很大的数,比如 1e6,这样代理模型就会认为这个区域不可取。
3.2 超参数搜索空间:不是拍脑袋,而是根据数据量来定
搜索空间的范围设计对贝叶斯优化来说非常重要。范围太小,最优值可能落在边界外面;范围太大,采样效率会降低。我根据自己的经验把常用变量的搜索范围整理成下面这张表。
| 优化变量 | 类型 | 搜索范围 | 设计理由 |
|---|---|---|---|
| windowSize | integer | [48, 336] | 小时级数据覆盖 2 天到 2 周,能体现周期背景;超过当前数据长度的上限就失去意义 |
| numLayers | integer | [1, 4] | 单变量数据量有限,层数太深容易过拟合,训练时间也快速上升 |
| numHeads | integer | [2, 8] | 头数不宜为 1,也不宜过多;需要能被 modelDim 整除 |
| modelDim | integer | [16, 64] | 单变量序列表达力有限,模型宽度应保持在适中范围 |
| learnRate | real | [1e-4, 1e-2] | 使用对数均匀分布,避免纯随机搜索撞到无效学习率 |
| dropout | real | [0.01, 0.3] | 兼顾正则强度与表达能力 |
窗口长度这个参数我会花更多心思。如果一个序列有明确的日周期或周周期,窗口至少要覆盖一个完整周期。数据采样频率是小时级,那 168 点是自然的周窗口;如果采样是分钟级,可以根据业务周期换算成点数。当你发现贝叶斯优化反复选中靠近范围边界的窗口值时,大概率是范围定窄了,需要先把边界扩大再看。
numHeads 与 modelDim 之间还有个整除约束,例如 modelDim=32 时,numHeads 可以选 2、4、8。在定义优化变量时,我会把 modelDim 设为 16 的倍数,这样灵活性更高。
3.3 bayesopt 的实际调用与收敛表现
Matlab 提供了bayesopt函数,和OptimizableVariable配合使用。下面是实际调用代码:
vars = [ optimizableVariable('windowSize', [48, 336], 'Type', 'integer') optimizableVariable('numLayers', [1, 4], 'Type', 'integer') optimizableVariable('numHeads', [2, 8], 'Type', 'integer') optimizableVariable('modelDim', [16, 64], 'Type', 'integer') optimizableVariable('learnRate', [1e-4, 1e-2], 'Transform', 'log') optimizableVariable('dropout', [0.01, 0.3], 'Type', 'real') ]; results = bayesopt(@(params) runTransformerWithParams(params, Xtrain, Ytrain, Xval, Yval), vars, ... 'MaxObjectiveEvaluations', 30, ... 'AcquisitionFunctionName', 'expected-improvement-plus', ... 'Verbose', 1, ... 'PlotFcn', {@plotObjectiveModel, @plotMinObjective});从我自己跑的结果看,前 10 次迭代下降非常快,通常能降到手调参数误差的 10% 到 20% 以内;之后进入平台期,后面的迭代更像是在边界附近微调。总迭代次数设 30 次是一个性价比不错的选择,如果你想更稳定,可以设 50 次,但每次迭代都是完整训练一个模型,耗时要事先想清楚。
关于采集函数,我习惯用expected-improvement-plus。它在普通 EI 的基础上加了一个“防止局部过采样”的机制,对于深度学习这种污染噪声较大的目标函数,稳定性明显更好。如果你用'expected-improvement'发现它反复在同一个点附近打转,换成这个带 plus 的版本通常能解决。
4. 核心实现细节:Matlab 代码里真正决定成败的几个段落
4.1 数据切割与滑窗:保证不发生“未来信息泄露”
时序预测的数据切分和普通机器学习不一样,不能随意打乱所有样本再切分。因为时间顺序本身就是信息的一部分,如果把未来数据混进训练集,验证集的成绩会虚高,但到了线上部署就立刻打回原形。
我的做法是严格按照时间顺序把完整序列切成训练集、验证集、测试集三段。比如一条 8000 点的序列,分别取前 6000 点做训练,中间 1000 点做验证,最后 1000 点做测试。这样保证验证和测试样本在时间上都在训练样本之后,更符合预测任务的实际场景。
归一化是第二个容易踩坑的点。很多教程直接把整条序列减均值除标准差,然后切分数据,这样做是有隐患的,因为测试集的均值是从整条序列计算来的,相当于让模型在训练时“看到了”一个与测试集相关的统计量。正确做法是先切分,再只用训练集计算均值和标准差,验证集和测试集一律复用训练集的统计量。
滑窗构造样本的代码我贴在这里,这是整个项目最基础但最容易出错的一步:
function [XTrain, YTrain] = makeSlidingWindows(data, windowSize, horizon, step) N = length(data); numSamples = floor((N - windowSize - horizon + 1) / step); XTrain = zeros(windowSize, 1, numSamples); YTrain = zeros(1, numSamples); idx = 1; for i = 1:step:(N - windowSize - horizon + 1) XTrain(:,:,idx) = data(i:i+windowSize-1); YTrain(:,idx) = data(i+windowSize+horizon-1); idx = idx + 1; end end这里的horizon表示预测未来第几步。默认设 1 就是单步预测;设成 6 就是预测未来第 6 个时刻的值。滑动窗口构造出来的样本,相邻样本共享大量重叠数据,训练时随机打乱顺序即可,验证集里一般不需要再随机打乱。
4.2 手动构建 Transformer 块的 Matlab 实现
如果 Deep Learning Toolbox 版本较旧,没有现成的transformerLayer,最可靠的方案是用attentionLayer加上layerNormalizationLayer和自定义残差来拼。我在第 2.2 节写了大致层结构,这里把关键点进一步展开。
嵌套式残差连接的实现并不复杂。在layerGraph中把一个层的输出同时接到加法层的两个输入上,就形成了残差。比如把嵌入层输出同时引入到注意力后做残差加法,可以避免深层网络中的梯度消失。
位置编码层因为需要可训练参数,正确做法是自定义一个layer子类。核心实现可以简化成:输入维度是[windowSize, modelDim],内部定义一个[windowSize, modelDim]的可学习矩阵,在predict方法里把输入与该矩阵相加并返回。这一层虽然简单,但在 Transformer 的收敛过程中作用非常关键,省略之后我测试过,验证集误差通常会增加 5% 以上。
注意力层的输入格式也需要对齐。Matlab 的attentionLayer默认支持带时间维的格式,用DataFormat指定为'CBT'可以明确告诉它模型维度、批量大小和时间步各自在哪一维。如果格式不匹配,通常在第一次训练时就会因为矩阵维度对不上而报错。
4.3 训练循环、早停与模型保存
用trainNetwork做训练最省事,但在贝叶斯优化这种需要严格可控训练过程的场景,我更推荐自定义训练循环。自定义循环的优势是早停逻辑自己说了算,不会因为某个 epoch 验证损失上升就继续傻跑。
自定义训练循环的基本框架如下:
% 网络与数据假设已转换为 dlarray for epoch = 1:maxEpochs shuffleIndices = randperm(size(XTrain, 3)); for i = 1:numMiniBatches idx = shuffleIndices((i-1)*miniBatchSize+1 : min(i*miniBatchSize, end)); XBatch = dlarray(XTrain(:,:,idx), 'CBT'); YBatch = dlarray(YTrain(:,idx), 'CB'); [loss, gradients] = dlfeval(@modelLoss, net, XBatch, YBatch); [net, state] = adamupdate(net, gradients, state, learnRate); end % 计算验证损失 valPred = predict(net, XValDL); valLoss = computeLoss(valPred, YValDL); % 早停判断 if valLoss < bestValLoss bestValLoss = valLoss; bestNet = net; patience = 0; else patience = patience + 1; if patience >= 10 break; end end endadamupdate是 Deep Learning Toolbox 内置的优化器更新函数,可以配合net.Learnables使用,比手动维护学习率简单。我在包装贝叶斯优化目标函数时,会直接把最终训练好的模型以结构体形式返回,让后续的测试集评估可以直接复用。
贝叶斯优化结束后,最优参数对应的模型可能存在于某一次迭代过程中。为了不丢失最优结果,我在训练过程中始终保留验证集损失最低的那个网络副本,而不是最后一次迭代的结果。这样做虽然多占一点内存,但能保证选择的模型是验证集上的最优位置。
4.4 预测与反归一化:把模型输出还原到原始量纲
模型在训练时输入的是归一化后的数据,输出自然也是归一化后的预测值。做测试集评估时,必须对预测结果做逆变换,才能和原始数据的量纲对比,否则算出来的 RMSE 和 MAPE 毫无业务意义。
逆归一化公式很简单:预测值乘上训练集的样本标准差,再加上训练集的均值。这段代码我放在预测之后统一处理:
% 假设 trainMean 和 trainStd 是在训练集上估计的均值和标准差 YpredNormalized = predict(bestNet, XTest); Ypred = YpredNormalized * trainStd + trainMean; % 计算指标 rmse = sqrt(mean((Ypred - Ytest).^2)); mape = mean(abs((Ypred - Ytest) ./ (Ytest + eps))) * 100; r2 = 1 - sum((Ypred - Ytest).^2) / sum((Ytest - mean(Ytest)).^2);实际绘图时,我习惯把测试集上的真实值和预测值画在同一个时间轴上,直观看出滞后和峰值处的偏差。如果预测曲线整体比真实曲线滞后一拍,往往说明窗口信息不够,需要增大窗口长度;如果峰值处明显偏低,则说明模型对突发变化的学习能力不足,可能需要调整自注意力的头数或层数。
5. 实测复盘的结论与踩坑记录
5.1 在几个代表性序列上的实测表现
为了验证这套 BO + Transformer 方案的通用性,我在三个不同类型的单变量序列上分别做了测试。
第一组是某压缩机振动信号序列,采样间隔为分钟级,总点数约 8000。信号带有明显周期性和随机尖峰,序列本身没有很强的趋势成分。贝叶斯优化给出的最佳窗口长度是 288 点,相当于最近 288 分钟,约一个中周期;层数选 2,d_model 为 32,学习率约 0.002。测试集上的 RMSE 是 0.031(归一化后),而同期配置的 LSTM 基线是 0.045。
第二组是某办公楼的用电负荷数据,小时级采样,总点数约 3500。负荷曲线受工作时段影响明显,日周期强但周周期较弱。最优窗口落在 168 点(一周),单层 Transformer 就已经够用,d_model 为 64。测试集 MAPE 达到 2.8%,预测曲线在早晚高峰的跟随非常准,这是 LSTM 很难做到的。
第三组是某仓库的出入库流量数据,噪声大、偶尔有突发高峰,总点数约 5000。这组数据的调参结果比较特殊,最优 dropout 达到了 0.15 附近,说明模型对抑制噪声敏感。最终测试集 RMSE 虽然不算惊艳,但比手工调参版本低了约 12%。
三组结果的共性是:窗口长度和 dropout 对最终误差影响最大。窗口定得太短会丢失周期依赖,太长则引入大量无关信息;dropout 决定模型对噪声的鲁棒性。学习率和层数反而相对容易搜到一个稳定区间,不需要反复精细调。
| 数据集 | 点数 | 最优窗口 | 最优层数 | optimal d_model | 测试RMSE | 对比LSTM |
|---|---|---|---|---|---|---|
| 压缩机振动 | 8000 | 288 | 2 | 32 | 0.031 | 0.045 |
| 办公负荷 | 3500 | 168 | 1 | 64 | 0.028 | 0.039 |
| 仓库流量 | 5000 | 96 | 2 | 32 | 0.042 | 0.048 |
5.2 我在这个项目里踩过的三个典型的坑
第一个坑是注意力计算时内存暴涨。当窗口长度设置到 500 点以上时,自注意力矩阵的尺寸会变成 500 乘 500,乘以 batch size 后占用的显存很快就不够用。表面看是程序报内存不足,本质是超参数搜索把窗口推得过大,而模型复杂度与窗口长度的平方成正比。解决办法是在目标函数里限制窗口上限,同时把 batch size 从 64 调到 32,保证大部分参数组合都能正常训练。
第二个坑是贝叶斯优化过程中突然出现 NaN 损失。训练初期学习率过高、或是某些特殊参数组合导致梯度爆炸,都会让损失变成 NaN。如果不处理,这个 NaN 会直接影响高斯过程代理模型的拟合,后续的采集点会往错误方向偏移。我在目标函数里加了一个保护分支:只要训练环节出现 NaN 或 Inf,直接返回 1e6,并把本次训练结果丢弃。
第三个坑是随机种子不一致导致比较失真。有一次优化结果看起来特别好,但复现后发现好成绩是因为那次迭代的随机初始化碰巧有效。重新评估同一组超参数时效果就差了不少。后来我把rng(42)写死在目标函数的第一行,同时固定了数据集的划分方式,贝叶斯优化的收敛曲线才真正稳定下来,不同参数之间的差异也变得更加可信。
5.3 给想复现这个方案的朋友几条实际建议
如果训练序列的总点数少于 1000,我建议不要直接上深层 Transformer。单层编码器加一个不错的窗口长度就已经足够,重点放在搜索窗口和学习率上,而不是堆层数。数据量不支撑太复杂的模型,结构压得越深越容易过拟合。
贝叶斯优化的迭代次数不必盲目加大。一次完整训练可能耗时几分钟到几十分钟,30 次迭代已经能覆盖超参数空间的主要区域。我的经验是,先跑 15 次左右的快速试迭代,观察最佳参数组合集中在哪个区间,再把对应变量的搜索范围缩小,继续跑第二轮。这种粗调到细调的思路比一次性跑 100 次更实际,也能省下大量训练时间。
另外,窗口长度和归一化顺序是很多人忽视的问题。我在 4.1 节里强调了先切分再归一化,但实际操作中还是能见到不少人把整条序列归一化后再切分。这个问题不解决,无论用什么模型,验证集结果都会虚高,最后部署线上则表现严重缩水。
6. 从单输入单输出还能往哪里走
6.1 多步预测:从一步到未来一段
这套方案目前做的是单步预测,输入一段历史窗口,输出下一时刻的一个值。实际业务里更长见的需求是预测未来一段时间的曲线,比如未来 24 小时的负荷。常见做法有两种:递归多步预测和直接多步预测。
递归的方式简单,把当前预测出的结果拼到窗口末尾,继续预测下一步,循环往复。缺点是误差会逐步累积,预测长度越长越不可靠。直接多步的方式则是把输出头的维度从 1 改成 H,让网络直接输出未来 H 个时刻的值。Transformer 的编码器结构可以直接复用,只需要改最后的fullyConnectedLayer(1)为fullyConnectedLayer(H)。
从我的测试经验来看,如果 H 在 12 点以内,递归预测和直接多步的差异不大;如果 H 达到 24 乃至 48,直接多步的稳定性明显更好。你也可以把这两者结合:先用 BO 选定窗口和模型结构,再在固定结构上做一个输出头层数的简单网格搜索。
6.2 把 BO 这套框架搬到别的模型上去
贝叶斯优化最大的价值其实不止于 Transformer。同样的目标函数封装思路,完全可以迁移到 LSTM、TCN、GRU 甚至 MLP 盆架上。只要把buildTransformer换成其他网络构建函数,搜索空间里的模型结构参数稍微调整,整个流程立刻复用。
我在日常项目里会给同一份数据同时跑两组贝叶斯优化,一组搜 LSTM 超参数,一组搜 Transformer 超参数,然后用各自最优结果做对比。这种方法比“先调好 LSTM,再凭经验改 Transformer 参数”要公平得多,也更容易给出有说服力的模型选型结论。
如果你后续要做多变量预测,也就是输入多个特征序列,改动比想象中小得多。把输入的维数从 1 改为 K,嵌入层输入尺寸改成 K,其余部分保持不变。搜索空间里的窗口长度、d_model、lnumHeads 几乎可以原封不动继续用。博文里讨论的所有避坑点,在多变量版本中同样成立。
我在实际跑完这套流程之后最大的感受是:BO 不是银弹,但它确实把调参从一门玄学变成了可复现的工程流程。你不需要比模型更懂超参数,只需要把搜索空间设计得合理,然后交给贝叶斯优化去探索。最后再分享一个小技巧:贝叶斯优化跑出来的最优参数经常落在搜索边界附近,这只说明你的范围定窄了。见到这种情况别急着收工,把对应变量的边界往外扩一圈,再追加十几轮迭代,通常会捡到更好的结果。