最近我在做时间序列预测方向的一个仿真实验,主题很直接:用PSO粒子群优化算法去搜索Transformer-BiLSTM混合网络的超参数,然后在MATLAB里完成训练、验证和性能对比。整套流程跑下来,我最大的感受是:这类混合模型的提升很多时候不来自“堆更多层”,而来自“把已有结构调整到合适的参数区间”。这篇笔记就把我搭建这套仿真管线时的方法、实验设计、踩过的坑和一些可以直接迁移的细节记录下来,给正好在做类似工作的朋友参考。
1. 为什么偏偏是这三个组合:PSO、Transformer和BiLSTM的搭配逻辑
1.1 从单层LSTM到双向结构,再到注意力机制
很多做序列预测的同学都经历过这个演进过程:先上LSTM,效果一般;然后想到把单向改成双向,利用未来时刻的信息辅助当前时刻的特征提取;再往后,注意力机制火了,又开始往模型里塞Transformer模块。
从原理上讲,这三者的梯度是清晰且互补的。LSTM擅长捕捉中等长度的时序依赖,但受限于递归结构,长距离信息的传递会衰减。BiLSTM比单向多了一条反向传播的路径,能够同时看到当前时刻之前的上下文和之后的上下文,对于离线训练的数据集,这种双向特征往往比单向稳定得多。Transformer走的是另一条路,它不依赖递归,而是通过自注意力计算任意两个时间点之间的相关性权重,因此对长距离依赖和周期性模式的捕捉比RNN类模型更直接。
我在这里的实验并不是把两者简单拼起来,而是把Transformer当作序列特征提取的第一级,输出再交给BiLSTM做上下文建模,最后接全连接层输出预测值。这样设计的理由是:Transformer先做全局相关性建模,BiLSTM再对提取后的特征序列做双向时序整合,避免两种结构“抢信息”。
1.2 混合模型的真正难点在于超参数空间
网络结构一旦确定,真正的麻烦就来了。Transformer-BiLSTM模型涉及的超参数非常多:注意力头数、嵌入维度、BiLSTM隐藏单元数、丢弃率、学习率、批大小、L2正则系数、训练轮数。这些参数之间还不是独立的,比如注意力维度要和隐藏单元数量匹配,学习率过高时丢弃率的容忍度又会变化。
网格搜索在这种空间里几乎不可行。如果每个维度取5个候选值,六七个维度组合出来的数量就是几千甚至上万组,每组都完整训练一次模型,时间上完全承受不起。随机搜索虽然比网格好一些,但缺乏方向性。
我选择PSO的核心原因是它不要求目标函数可导,也不需要知道搜索空间的梯度信息。PSO只需要一个适应度函数,能够评价“这一组超参数下训练出来的模型效果如何”。恰好训练一个网络并计算验证集误差,就是一个天然的黑盒评价过程。实践下来,粒子群优化在15到30代之内就能收敛到一组比较合理的参数,这个效率对于混合模型的超参数搜索来说是可以接受的。
1.3 这套方案适合谁,不适合谁
如果你正在做论文实验、课程设计、算法对比,或者想验证一个预测想法是否成立,这套方案非常合适。PSO帮你省掉了“手动试参”的大量时间,而且它输出一组较优参数的过程本身,也可以作为实验分析的一部分。
但如果你要做在线实时预测或者工业级部署,我的建议是谨慎。因为每个粒子在PSO迭代过程中都需要完整训练一次模型,计算开销不小,整个调参流水线并不适合频繁更新。对于在线场景,通常先离线调好参数,再固定模型做滚动预测,而不是在线跑PSO。
2. 模型的数据流与MATLAB搭建思路:别把网络当成黑盒
2.1 输入序列如何变成训练样本
时间序列预测的第一步永远是数据预处理。我在这套仿真里采用滑窗方式构造样本:输入一段长度为历史窗口的连续观测值,预测窗口之后的一个或多个时间点。
比如原始序列是x(1)到x(N),滑窗大小设为窗口长度,那么第k个样本就是:
- 输入特征:[x(k), x(k+1), ..., x(k+窗口长度-1)]
- 预测标签:x(k+窗口长度)
窗口长度我试过24、48和96。对于日周期的电力负荷数据,96是比较稳妥的选择,因为它差不多覆盖了一整天的96个15分钟采样点,模型能同时看到近期变化趋势和昨日同时段的形态。对于更平滑的气象数据,48就够用。
归一化这一步非常关键。这里不是指公式难度,而是指信息泄漏问题。我一开始图省事,直接对整个数据集做归一化再划分训练集和验证集,后来发现验证指标虚高,原因在于验证集数据的均值和标准差已经参与了训练样本的标准化计算。正确做法是只从训练集中计算均值和标准差,然后用同一组统计量去转化验证集和测试集。
2.2 Transformer与BiLSTM的前向计算路径
我在MATLAB中用dlnetwork组织模型,而不是直接用trainNetwork。原因是trainNetwork对自定义复杂连接的支持有限,而Transformer加BiLSTM的组合用自定义训练循环会更灵活。
前向计算路径大致如下:
- 输入序列经过位置编码层,给每个时间步加上位置信息;
- 经过多头自注意力模块,计算序列内部的关联权重;
- 注意力输出做残差连接和层归一化;
- 送入前馈网络,再经过一次残差和归一化;
- 把上面得到的特征序列输入BiLSTM层,提取双向时序特征;
- BiLSTM输出展平或取最后时间步,接全连接层,输出预测值。
这里有一个容易搞错的细节:如果你做的是因果预测,即用过去预测未来,那么自注意力必须使用因果掩码,让每个时间步只能看到当前及之前的位置。如果数据集本身是离线训练的,并且你允许双向LSTM利用未来信息,那自注意力这一步也可以不遮罩。我在实验里为了对比公平,统一做了因果掩码处理。
2.3 MATLAB层面的数据格式与训练循环
使用dlnetwork之后,输入数据的基本单位是dlarray。在MATLAB的深度学习工具箱里,序列数据常用格式标记来表示维度,我自己习惯用通道、批量、时间顺序。你也可以根据版本习惯调整,关键是要保证自定义前向函数里各层之间的数据形状始终匹配。
训练循环的基本骨架是:
- 打乱训练样本顺序;
- 按批大小切分mini-batch;
- 把输入、标签分别转成dlarray;
- 对每个可训练参数计算梯度;
- 用优化器更新参数。
我之所以坚持用自定义训练循环,是因为在PSO的适应度评价里,我需要反复修改模型的超参数并重新训练。这个时候trainNetwork反而是不方便的,它的超参数配置会绑定到训练选项里,而自定义循环把所有控制权交到我们自己手里。
3. PSO优化环节:如何让粒子群给模型“定参数”
3.1 粒子编码的设计是第一步
PSO的粒子本质是一组连续值向量。把超参数映射成粒子的维度,这个过程叫编码。我在这套实验里的编码方案是:
| 粒子维度 | 对应超参数 | 搜索范围 | 编码方式 |
|---|---|---|---|
| 1 | 学习率 | [1e-4, 1e-2] | 取log10后映射到[-4, -2] |
| 2 | BiLSTM隐藏单元数 | [16, 128] | 四舍五入取整数 |
| 3 | 注意力头数 | [1, 8] | 四舍五入取整数 |
| 4 | 丢弃率 | [0.05, 0.5] | 线性映射到[0.05, 0.5] |
| 5 | L2正则系数 | [1e-6, 1e-3] | 取log10后映射到[-6, -3] |
学习率和L2系数用对数映射,是因为这些参数在指数尺度上变化。直接在[0.0001, 0.01]上做线性搜索会导致大部分粒子落在大数值区域,小数值区域几乎得不到尝试。log映射后,搜索空间在整个数量级范围内均匀分配,效率会高很多。隐藏单元数和注意力头数虽然在粒子里是连续值,但我取评价函数的时候会四舍五入成整数。这里有个约束要特别注意:隐藏单元数需要能被注意力头数整除,否则在模型构建阶段会报维度错误。我干脆在评价函数里加了合法性检查,不合法就直接给一个很大的适应度值,让粒子自己避开这类无效解。
3.2 适应度函数怎么写才客观
适应度函数是整个PSO流程里最重要,也最容易出问题的地方。我在这里用验证集上的误差作为适应度值,而不是训练集误差。原因很直接:我们要的是泛化能力,不是对训练样本的死记硬背。
实际评价流程是:
- 根据粒子解析出超参数;
- 构建Transformer-BiLSTM网络;
- 在训练集上训练若干个epoch;
- 在验证集上计算预测误差;
- 把误差返回给PSO作为该粒子的适应度。
这里有一个实验控制细节:粒子群优化过程中,每个配置的训练epoch数必须保持一致,否则模型训练程度不同,比较就不公平。我用的是固定30个epoch,不加入早停,这样整个搜索过程的时间消耗是可控的。
适应度函数可以选择RMSE或者MAPE。对于电力负荷这类数值相对稳定的序列,MAPE更直观;但如果序列里存在接近零的数值,MAPE会被异常放大,此时用RMSE更稳健。我两个指标都计算,PSO内部用RMSE作为排序依据,最后再用MAPE和R2做结果分析。
3.3 PSO主循环的核心公式与MATLAB框架
标准PSO的更新公式并不复杂。每个粒子同时记录当前位置、历史最优位置,以及当前速度。速度更新公式是:
v = w * v + c1 * r1 * (pbest - x) + c2 * r2 * (gbest - x)
其中w是惯性权重,c1是自我认知系数,c2是社会认知系数,r1和r2是[0,1]之间的随机数。位置更新则是当前速度加当前位置。
我一般在实验里取c1=1.5,c2=1.5,w从0.9开始线性递减到0.4。这个设置是经验值,不一定最优,但比较稳。w大时粒子探索范围大,迭代后期w减小,粒子趋向精细搜索。
MATLAB实现的核心循环大致是:
for iter = 1:maxIter for i = 1:popSize r1 = rand(size(pos)); r2 = rand(size(pos)); vel(i,:) = w * vel(i,:) + c1 * r1 .* (pbest(i,:) - pos(i,:)) ... + c2 * r2 .* (gbest - pos(i,:)); pos(i,:) = pos(i,:) + vel(i,:); pos(i,:) = constrain(pos(i,:), lb, ub); fitness(i) = evaluateModel(pos(i,:)); if fitness(i) < fitness_pbest(i) pbest(i,:) = pos(i,:); fitness_pbest(i) = fitness(i); end end [minVal, idx] = min(fitness_pbest); if minVal < fitness_gbest gbest = pbest(idx,:); fitness_gbest = minVal; end w = 0.9 - (0.9 - 0.4) * iter / maxIter; end这段代码是一个高度概括的框架,实际使用时要补充边界处理、粒子初始化和结果记录。粒子数量我设为20到25个,迭代次数设为15到30代。这个规模在CPU上跑,单个数据集大概需要几个小时,属于可接受范围。
4. 性能仿真实验怎么设计:数据集、指标与对比组
4.1 数据集选择:两个不同性质的序列
我只在一个数据集上测模型,说服力是不够的。这次实验准备了两个数据集。
第一个是模拟某台区电力负荷曲线,有比较明显的日周期和季节趋势,噪声较小,适合检验模型对周期模式的学习能力。第二个是气象类的观测序列,波动更随机,存在一些突变点,用来考验模型对不稳定序列的适应能力。
对于手头没有真实数据的读者,我的建议是自己构造一个合成序列用于调试代码,比如正弦波加趋势项加随机噪声,这样能快速确认模型和优化流程是否跑通。等整体流程没有问题,再替换成真实数据。千万不要一开始就用复杂真实数据调代码,出了问题很难定位是网络结构的问题还是数据预处理的问题。
4.2 评价指标的计算方式
我最终报告里用了四个指标:RMSE、MAE、MAPE和R2。
RMSE是均方根误差,对大误差敏感,适合衡量预测偏离程度;MAE是平均绝对误差,更直观;MAPE是平均百分比误差,方便在业务中理解;R2反映模型对序列方差的解释能力。
R2的计算公式可以写成:
R2 = 1 - SS_res / SS_tot
其中SS_res是残差平方和,SS_tot是真实值与其均值之差的平方和。R2越接近1表示拟合效果越好。需要提醒的是,对于时间序列预测,R2受到序列自身平稳性的影响比较大,平稳性越强的序列,R2天然会偏高,所以不能只看R2,还要结合RMSE和MAE一起判断。
4.3 对比实验组怎么设置
为了说明PSO优化和Transformer-BiLSTM混合结构的价值,我设置了四组实验:
| 实验组 | 说明 |
|---|---|
| BiLSTM基线 | 单BiLSTM网络,超参数用手工经验设置 |
| Transformer基线 | 纯Transformer网络,超参数用手工设置 |
| Transformer-BiLSTM固定参数 | 混合网络,但不做自动优化,用手工试出来的参数 |
| PSO-Transformer-BiLSTM | 混合网络,超参数由PSO搜索得到 |
每组实验都在相同的训练集、验证集、测试集划分下进行,并且每组用多个随机种子训练多次,报告平均值和标准差,而不是只跑一次。单次训练的结果偶然性太大,不同初始化下的表现差异有时候比不同模型之间的差异还大。
4.4 结果怎么判读
从仿真结果看,PSO优化后的混合模型在测试集上的RMSE比固定参数混合模型下降了大约12%,比纯Transformer和纯BiLSTM也有明显改善。更值得关注的不是这个数字本身,而是PSO搜索出来的一组参数:注意力头数落在4到6之间,BiLSTM隐藏单元数落在64到96之间,学习率落在3e-3附近。这说明实验中手工设定的参数虽然合理,但离最优区间还是有明显偏差的,优先做全面调参再判断模型性能排名是很必要的。
另一个有意思的现象是,纯Transformer对长周期趋势的拟合更好,但对局部突变反应偏慢;BiLSTM则相反,短期波动跟得紧,长周期趋势容易偏移。混合模型恰好把两者互补起来,在测试集上的误差没有出现某一类特征上的明显短板。
5. 踩坑全过程:五个可能拖慢整个实验进度的细节
5.1 第一坑:标准化统计量里混进了验证集信息
这个问题我前面已经提到,但值得单独再说一遍。第一次做PSO优化时,我把整个数据集统一做归一化,然后划分训练集、验证集和测试集。从误差曲线上看,适应度下降得非常漂亮,但拿到真正没参与训练的测试集上一测,误差却高了不少。
排查到最后发现,罪魁祸首就是归一化统计量。验证集和测试集的均值和标准差已经通过全数据集统计进入了模型训练阶段,验证集误差被严重低估。修复方法很简单:先划分数据,再从训练集计算统计量。这个坑之所以隐蔽,是因为训练误差和验证误差都正常下降,只有测试误差能暴露问题。
5.2 第二坑:注意力头数和隐藏单元维度对不上
PSO粒子里的注意力头数和BiLSTM隐藏单元数都是离散值,如果两者选取不当,模型构建阶段就会报维度不匹配的错。
比如隐藏单元数是32,注意力头数选5,这时候注意力层的维度分割会出现问题。表面看是代码报错,实质上是编码时没有考虑约束条件。我在适应度函数的最前面增加了一个合法性检查:如果隐藏单元数不能被注意力头数整除,直接返回一个极大值,让PSO自动淘汰这种配置。这个办法简单粗暴,但很有效,不需要在粒子更新阶段强行套约束规则。
5.3 第三坑:粒子群陷入局部最优,反复评估同一个位置
第一次跑PSO的时候,我发现在迭代后期很多粒子几乎不动,适应度也长时间没有变化。查日志发现,有些粒子一直停留在同一个小区域里,速度更新后位置变化非常小。
原因主要是我初始惯性权重没有做递减,后期速度依然偏小,粒子失去了逃逸能力。解决方法是把w从一开始就没设好,改成线性递减策略,并且当全局最优连续5代没有改善时,对部分粒子做一个局部扰动,重新初始化一下部分粒子的位置。这个机制能明显缓解停滞问题,但也别太激进,否则粒子群容易退化成随机搜索。
5.4 第四坑:多次运行结果复现不了
MATLAB里的随机数生成器如果不固定,每次运行的结果都会有差异。这在PSO优化里尤其明显,因为粒子初始化、随机速度扰动、网络权重初始化都是随机的。
为了让实验结果可复现,我在主程序开头统一调用固定随机种子函数。这样做之后,在同一台机器上重复实验,结果几乎完全一致。但换到GPU或并行环境时,由于并行线程调度顺序不确定,完全复现会更难。如果需要严格对比,我建议在CPU上做最终验证,并在报告的表格里记录随机种子信息。
5.5 第五坑:PSO阶段训练时间失控
另一个容易低估的问题是时间预算。如果每个粒子训练100个epoch,25个粒子迭代30代,意味着要训练750个模型,每个模型100个epoch,时间完全失控。
我把PSO阶段和最终训练阶段分开做了设置。PSO阶段每个配置只训练30个epoch,并且验证集评价频率降低,只用于适应度排序,不需要找到最终模型。等PSO收敛出最佳参数后,再单独用这批参数做一次完整的长时间训练和详细指标计算。这么做既保证了搜索效率,也保证了最终模型的性能。
6. 把整套仿真程序组织成可复用的实验管线
6.1 文件结构与职责划分
写到后面我发现,这类实验代码很容易变成一团乱麻。为了让整个流程可以复用,我按功能拆成了几个脚本:
- 数据加载与预处理脚本:读取原始序列,滑窗生成样本,完成归一化;
- 模型构建函数:根据超参数构建dlnetwork结构;
- 适应度函数:训练一定轮数并返回验证集误差;
- PSO主程序:粒子初始化、迭代更新、结果记录;
- 最终训练与评估脚本:用最优参数做最终训练,计算各项指标;
- 绘图脚本:绘制训练曲线、预测对比图。
这样的结构让每个环节都可以单独调试。PSO主程序报错时不需要去翻模型构建细节,模型构建报错时也不需要和粒子更新逻辑混在一起看。
6.2 训练循环与PSO循环解耦
一个特别容易忽略的点是:适应度函数每次被调用,都应该创建一个全新的模型对象,或者把模型的训练参数重置到初始状态。如果直接复用同一个dlnetwork对象反复训练,参数的初始状态会继承上一次训练的结果,导致后处理的粒子适应度被污染,整个PSO的比较基准就不公平了。
实际做法是,在适应度函数内部调用模型构建函数,每次生成新的网络实例。虽然这样做会带来一些额外的构造开销,但保证了粒子之间互不干扰。
6.3 这个方案后续还能怎么扩展
这套实验管线并不局限在Transformer-BiLSTM这一个结构上。我后来尝试过几个方向的扩展,都证明改动成本很低。
一个方向是把MSE损失换成分位数损失,这样模型输出就不只是一个点预测值,而是多个分位数的预测区间,适合做不确定性的区间预测。另一个方向是在PSO的粒子维度里加入训练参数,比如批大小和训练轮数,代价是搜索空间变大,迭代次数需要适当增加。如果再激进一点,还可以把优化器从普通PSO换成带惯性衰减和局部重生的变体,收敛质量会更好,不过要多写不少代码。
最后分享一点个人体会。做这种混合模型加优化算法的实验,最大的风险不是模型不够复杂,而是实验流程不够规范。PSO本身只是寻找一组更合适的超参数,不会突然把一个糟糕的网络结构变成神奇模型。真正值得投入时间的地方是数据划分、标准化方式、适应度函数的设计和实验对照的一致性。把这些基础工作做扎实了,再去看PSO搜索出来的参数,你会发现自己对模型的理解比单纯跑几个对比实验要深得多。