1. 整体思路:LSTM 和 Transformer 编码器在 MATLAB 里怎么“合体”
先说结论:这个项目不是拿 LSTM 和 Transformer 各跑一遍再取平均,而是把两者串成一个“接力”结构。LSTM 负责把原始时间序列先过一遍,提取出局部时序特征,再把 LSTM 的输出序列交给 Transformer 编码器,由多头自注意力去捕捉长时间跨度的依赖关系,最后经过一个回归头输出预测值。我在 MATLAB 里实测下来,这种结构在单变量和多变量时间序列预测上,都比单独用 LSTM 或者单用 Transformer 编码器更稳,尤其是在序列中同时存在“短期波形规律”和“长期趋势漂移”的场景。
为什么这么设计?LSTM 天生的门控机制擅长记住“最近一段时间的模式”,但序列一长,它的有效记忆窗口还是受限的,靠堆层数缓解又容易过拟合。Transformer 编码器里的自注意力机制可以让任意两个时间步直接建立联系,建模长程依赖很强,但如果直接把原始序列喂给它,它在高频噪声和局部波形上的敏感性反而不如 LSTM。两者级联的价值在于:LSTM 先做一次“特征提纯”,把原始序列压缩成更高层的时序表示,Transformer 编码器再在这个表示上做全局关系建模。打个比方,LSTM 相当于一个经验丰富的老师傅先把卷子里的关键线索圈出来,Transformer 编码器再根据这些线索做综合分析,最后输出一个靠谱的分数。
这个项目适合谁参考?想学 MATLAB 深度学习时序预测的读者、需要在 MATLAB 里实现 SOTA 级预测模型但不想碰 Python 的从业者、以及想搞清楚自定义训练流程和 GUI 集成的同学。我会把数据处理、模型搭建、训练配置、GUI 回调全部拆开讲。
2. 数据准备:合成信号虽然简单,套路不能省
2.1 测试数据怎么构造才合理
为了让大家能完整跑通流程,我先用合成信号做演示。合成数据的好处是规律已知、可复现、没有隐私问题,等流程跑通了,把数据替换成自己的业务数据即可。
我生成了一段 3000 点的序列,包含两个不同频率的正弦波叠加,再加一点噪声。低频正弦模拟长期趋势,高频正弦模拟短期波动,噪声模拟真实采集场景里的干扰。
clear; close all; rng(42); N = 3000; fs = 10; % 采样率 10Hz t = (0:N-1)' / fs; y = 5 * sin(2*pi*0.03*t) + 2 * sin(2*pi*0.01*t) + 0.5 * randn(N, 1); figure; plot(t, y); xlabel('时间 (s)'); ylabel('幅值'); title('合成时间序列');这里把随机种子固定在 42,是为了保证每次运行生成的数据一致,方便对照结果。实际项目中,真实采集数据往往包含缺失值和异常点,需要先做插值或者剔除,否则后面滑动窗口切出来的样本里会混入脏数据。
2.2 滑动窗口切样本的细节
时序预测不能像普通回归那样随机划分训练集和测试集,因为序列本身有时间顺序,乱序会导致数据泄漏。我用滑动窗口的方式切样本:每个样本取连续winSize个历史点作为输入特征,预测未来第horizon个点的值。
窗口长度是核心超参数。窗口太长,样本数量变少,而且早期信息可能与预测目标关系不大;窗口太短,模型看不到足够的周期背景。我测试了几个组合,最终选了winSize = 200、horizon = 1,效果比较均衡。
function [X, Y] = createSlidingWindows(series, winSize, horizon) n = length(series); numSamples = n - winSize - horizon + 1; X = cell(numSamples, 1); Y = zeros(numSamples, 1); for i = 1:numSamples X{i} = series(i:i+winSize-1)'; Y(i) = series(i+winSize+horizon-1); end end注意,X必须是 cell 数组,每个 cell 里是一个1 x winSize的矩阵。这是sequenceInputLayer要求的格式,代表一个样本的“单特征多时间步”数据。如果做多变量预测,就把每个 cell 改成C x winSize,其中 C 是特征维度。
2.3 归一化必须只统计训练集
很多新手在这里踩坑:直接拿整段序列做归一化,测试集的信息提前进入了训练过程,结果指标虚高。正确做法是只用训练集部分统计均值和标准差。
winSize = 200; horizon = 1; trainLen = round(N * 0.8); trainSeries = y(1:trainLen); testSeries = y(trainLen+1:end); % 只用训练集统计归一化参数 mu = mean(trainSeries); sg = std(trainSeries); trainSeries = (trainSeries - mu) / sg; testSeries = (testSeries - mu) / sg; [XTest, YTest] = createSlidingWindows(testSeries, winSize, horizon);切分时训练集和测试集之间要留出winSize + horizon - 1个点的重叠缓冲,否则测试样本会包含训练集末尾的数据。上面的代码里,testSeries从trainLen + 1开始,但createSlidingWindows会取前winSize个点做输入,所以测试集的第一个样本实际上会用到训练集最后一段历史数据——这在时序预测里是合理的,因为预测本身就是基于已知历史递推的。
3. 模型核心实现:LSTM 特征提取 + Transformer 编码器
3.1 用 layerGraph 搭模型
MATLAB 里搭这种混合模型,我建议直接用layerGraph拼接,不建议写成单独的network类,因为后者在训练选项和可视化方面限制太多。整体结构如下:
sequenceInputLayer接收原始序列lstmLayer保持OutputMode='sequence',输出完整的序列特征multiHeadSelfAttention多头自注意力,这是 Transformer 编码器的核心- 残差连接 + 层归一化
- 前馈网络 FFN + 残差连接 + 层归一化
- 全局平均池化,把序列特征压成一个向量
- 全连接层输出预测值
function lgraph = buildLSTMAttentionNet() lstmUnits = 64; numHeads = 4; fcSize = 32; lgraph = layerGraph(); lgraph = addLayers(lgraph, sequenceInputLayer(1, 'Name', 'input')); lgraph = addLayers(lgraph, ... lstmLayer(lstmUnits, 'OutputMode', 'sequence', 'Name', 'lstm')); lgraph = addLayers(lgraph, ... multiHeadSelfAttention(numHeads, 'Name', 'attention')); lgraph = addLayers(lgraph, additionLayer(2, 'Name', 'res1')); lgraph = addLayers(lgraph, layerNormalizationLayer('Name', 'ln1')); lgraph = addLayers(lgraph, fullyConnectedLayer(fcSize, 'Name', 'ffn1')); lgraph = addLayers(lgraph, reluLayer('Name', 'relu1')); lgraph = addLayers(lgraph, fullyConnectedLayer(lstmUnits, 'Name', 'ffn2')); lgraph = addLayers(lgraph, additionLayer(2, 'Name', 'res2')); lgraph = addLayers(lgraph, layerNormalizationLayer('Name', 'ln2')); lgraph = addLayers(lgraph, globalAveragePooling1dLayer('Name', 'gap')); lgraph = addLayers(lgraph, fullyConnectedLayer(1, 'Name', 'output')); lgraph = connectLayers(lgraph, 'input', 'lstm'); lgraph = connectLayers(lgraph, 'lstm', 'attention'); lgraph = connectLayers(lgraph, 'attention', 'res1/in1'); lgraph = connectLayers(lgraph, 'lstm', 'res1/in2'); lgraph = connectLayers(lgraph, 'res1', 'ln1'); lgraph = connectLayers(lgraph, 'ln1', 'ffn1'); lgraph = connectLayers(lgraph, 'ffn1', 'relu1'); lgraph = connectLayers(lgraph, 'relu1', 'ffn2'); lgraph = connectLayers(lgraph, 'ln1', 'res2/in1'); lgraph = connectLayers(lgraph, 'ffn2', 'res2/in2'); lgraph = connectLayers(lgraph, 'res2', 'ln2'); lgraph = connectLayers(lgraph, 'ln2', 'gap'); lgraph = connectLayers(lgraph, 'gap', 'output'); end这里有个关键点:multiHeadSelfAttention的输入通道数取决于上一层的输出通道数,也就是 LSTM 的隐藏单元数lstmUnits = 64。它要求numHeads能整除通道数,4 整除 64,所以没问题。如果以后修改lstmUnits,记得同步调整numHeads。
3.2 为什么这里要用全局平均池化而不是直接取最后一个时间步
很多初学者在序列回归任务里喜欢直接用 LSTM 的OutputMode='last',但这在我们这个结构里行不通——如果第一层 LSTM 就输出 last,Transformer 编码器就收不到序列了。即使后面再接 Transformer,也会丢失 LSTM 输出的时序信息。
所以我让 LSTM 输出完整序列,经过编码器后再用全局平均池化把所有时间步的信息融合成一个向量。实测下来,全局平均池化比直接取最后一个时间步更稳定,因为最后一个时间步可能正好处于局部噪声较大的位置,而平均操作天然带有平滑效果,类似于对整个序列的“综合打分”。
3.3 关于multiHeadSelfAttention的版本说明
这个内置层需要 MATLAB R2023a 及以后的版本才可用。如果你的版本比较旧,就需要自己写一个缩放点积注意力层。这里给出一个可行思路:自定义一个nnet.layer.Layer子类,内部用dlarray实现 Q、K、V 的线性变换和点积缩放。
classdef MyAttentionLayer < nnet.layer.Layer % 简化版多头自注意力,需自行实现 predict 和 backward properties NumHeads KeyChannels end properties (Learnable) Wq, Wk, Wv, Wo end methods function layer = MyAttentionLayer(numHeads) layer.Name = 'myattention'; layer.NumHeads = numHeads; end function Z = predict(layer, X) % X: [C, T, B] % 这里省略:将 X reshape 成多头格式,分别计算 Q K V, % 点积缩放,softmax,最后 Wo 融合 end end end不过自己实现注意力层,最麻烦的是backward的梯度推导,稍有疏漏反向传播就会出问题。所以我强烈建议:能用内置层就用内置层,只有版本被卡住了才折腾自定义层。
4. 训练配置与超参数:让混合模型真正收敛
4.1 训练选项的一个“黄金组合”
模型搭好之后,训练选项直接决定能不能收敛。我调试时用了一组参数,实测下来比较稳:
options = trainingOptions('adam', ... 'MaxEpochs', 80, ... 'MiniBatchSize', 64, ... 'InitialLearnRate', 0.005, ... 'LearnRateSchedule', 'piecewise', ... 'LearnRateDropFactor', 0.5, ... 'LearnRateDropPeriod', 20, ... 'GradientThreshold', 1, ... 'Shuffle', 'every-epoch', ... 'Plots', 'training-progress', ... 'Verbose', 1); net = trainNetwork(XTrain, YTrain, lgraph, options);这里逐一说下怎么想的。GradientThreshold设为 1 极其重要,Transformer 编码器在训练初期容易出现梯度震荡,梯度裁剪能防止某个 batch 把整个模型参数冲飞。学习率设成 0.005 而不是 0.01,是因为混合模型比单一 LSTM 更敏感,初始学习率小一点更稳妥。每隔 20 个 epoch 学习率减半,让训练后期用更小的步长精调参数。
4.2 损失函数和评估指标选什么
trainNetwork默认可以使用'mse'作为损失,回归任务选它没问题。但只盯着 MSE 不够,我在评估阶段还算了 RMSE、MAPE 和 R²。
YPred = predict(net, XTest); yPred = YPred * sg + mu; yTrue = YTest * sg + mu; rmse = sqrt(mean((yPred - yTrue).^2)); mape = mean(abs((yTrue - yPred) ./ yTrue)) * 100; ssRes = sum((yTrue - yPred).^2); ssTot = sum((yTrue - mean(yTrue)).^2); r2 = 1 - ssRes / ssTot;RMSE 对大的预测误差比较敏感,适合判断有没有个别点预测崩掉;MAPE 以百分比形式反映整体误差水平,业务汇报时用得多;R² 判断模型是否抓住了序列的主要变化趋势。三个指标一起看,不要只挑好看的那个。我在实际项目里就遇到过 RMSE 很低但 MAPE 很高的情况,说明大部分点预测得很准,但个别小数值点的相对误差极大,这种时候就要检查是不是对异常小值敏感。
4.3 训练过程中怎么判断模型有没有学到“真东西”
除了看训练损失曲线,我建议在训练结束后把测试集前 300 个样本的真实值和预测值画在一起,人眼确认一下。曲线形态对得上、相位基本一致,说明模型学到的是序列的动态规律;如果预测结果只是比真实值延迟了一个时间步,那多半是模型学会了“直接把上一个值当预测值”,这在时间序列里叫滞后预测,本质上是没学到东西。
figure; plot(yTrue(1:300), 'LineWidth', 1.5); hold on; plot(yPred(1:300), 'LineWidth', 1.5); legend('真实值', '预测值'); title('测试集前300点预测对比');5. GUI 设计:把模型操作包装成一键流程
5.1 界面布局的核心思路
命令行脚本调通之后,我习惯再包一层 GUI,因为实际落地时,使用方往往不是写代码的人,他们需要一个可视化界面完成“导入数据—训练—预测—看结果”的闭环。我用uifigure做界面,控件简单直接:
- 数据区:一个“载入数据”按钮、一个“预览数据”按钮
- 模型区:一个“开始训练”按钮、一个“加载已训练模型”按钮
- 预测区:一个“单步预测”按钮、一个“多步预测”按钮
- 结果显示:两个坐标轴,左边画整体拟合,右边画最近一段放大图
- 指标栏:一个文本框显示 RMSE、MAPE、R²
function createGUI() fig = uifigure('Position', [100 100 900 600], 'Name', 'LSTM-Transformer 时间序列预测'); g = uigridlayout(fig, [2 2]); g.RowHeight = [60, -1]; g.ColumnWidth = {350, -1}; % 左侧控制面板 leftPanel = uipanel(g, 'Title', '控制面板'); grid = uigridlayout(leftPanel, [6 1]); btnLoad = uibutton(grid, 'Text', '载入数据', 'ButtonPushedFcn', @(src,evt) loadDataCallback(src, evt, fig)); btnPreview = uibutton(grid, 'Text', '预览数据', 'ButtonPushedFcn', @(src,evt) previewDataCallback(src, evt, fig)); btnTrain = uibutton(grid, 'Text', '开始训练', 'ButtonPushedFcn', @(src,evt) trainCallback(src, evt, fig)); btnLoadModel = uibutton(grid, 'Text', '加载已训练模型', 'ButtonPushedFcn', @(src,evt) loadModelCallback(src, evt, fig)); btnPredict = uibutton(grid, 'Text', '单步预测', 'ButtonPushedFcn', @(src,evt) predictCallback(src, evt, fig)); lblMetric = uilabel(grid, 'Text', '指标: 未计算'); lblMetric.FontColor = [0.2 0.2 0.8]; % 右侧绘图区 ax = uiaxes(g); ax2 = uiaxes(g); end5.2 数据共享用setappdata而不是全局变量
GUI 回调函数之间需要共享数据,我见过同学用全局变量传数据,程序一长就莫名其妙被覆盖。MATLAB 的setappdata/getappdata是更规范的做法,把数据挂在 figure 对象上。
function loadDataCallback(~, ~, fig) [file, path] = uigetfile('*.mat', '选择数据文件'); if isequal(file, 0) return; end S = load(fullfile(path, file)); setappdata(fig, 'data', S.y); uialert(fig, '数据加载成功', '提示'); end训练回调里,从getappdata取数据,训练完成后把模型网络结构、归一化参数、窗口大小等全部存到同一个 appdata 里。这样后续预测回调只需要从 appdata 拿模型和参数,互不干扰。
5.3 预测回调里最容易犯的错:忘了还原归一化
预测回调里,很多人从网络输出拿到YPred就直接画图,结果曲线严重偏离真实值。因为训练时数据被标准化过,网络的输出也是标准化空间的数值,必须用之前保存的mu和sg还原到原始量纲。
function predictCallback(~, ~, fig) net = getappdata(fig, 'net'); meta = getappdata(fig, 'meta'); yData = getappdata(fig, 'data'); mu = meta.mu; sg = meta.sg; winSize = meta.winSize; lastSeq = yData(end-winSize+1:end); lastSeq = (lastSeq - mu) / sg; yPredNorm = predict(net, {lastSeq'}); yPred = yPredNorm * sg + mu; setappdata(fig, 'lastPred', yPred); uilabel(findobj(fig, 'Text', '指标: 未计算'), 'Text', ... sprintf('最新预测值: %.3f', yPred)); end注意这里{lastSeq'}的转置,因为predict要求 cell 内部是特征数 x 序列长度的格式,单变量情况下特征数是 1,所以要转置成行向量。
6. 常见问题与调试实录
6.1 训练时报错:维度不匹配
最常见的是multiHeadSelfAttention的输入通道被numHeads整除不了。比如你改小了 LSTM 隐藏单元数到 30,又设了 4 个头,那 30 除以 4 有余数,MATLAB 直接报错。解决办法是让lstmUnits始终是numHeads的整数倍,或者干脆动态设置头的数量:
numHeads = 4; lstmUnits = numHeads * 16;如果是自定义层版本,大概率是 Q/K/V 矩阵相乘后的维度没对齐。排查方法是在predict里临时打印每一步的size,定位是哪个 reshape 出了问题。
6.2 Transformer 在小数据集上过拟合
Transformer 编码器的参数量比 LSTM 大不少,数据量小时很容易训练损失下降很快、测试指标反而很差。我的经验是,数据量低于几千个样本时,先缩小模型规模:lstmUnits从 64 降到 32,FFN 隐藏层从 32 降到 16,并加早停策略。如果数据量实在不够,另一种思路是先用单 LSTM 跑一版拿基线,再叠加 Transformer 看提升幅度是否值得。
6.3 预测曲线明显滞后
预测滞后是时序模型的经典问题。原因通常是模型学到了“把上一个观测值搬到未来”,特别是序列自相关性极强的时候。排查思路:把预测结果和真实值的互相关看一阶,如果峰值出现在 lag=1,基本就是滞后。改进方法:一是增大horizon,逼模型学习多步规律;二是在损失中增加对预测变化量的惩罚,比如把差分项加入 loss。
6.4 训练时间长到无法接受
LSTM 加 Transformer 编码器的计算量不低,CPU 上跑几百个 epoch 可能要几十分钟到几小时。如果只有 CPU,可以把lstmUnits和fcSize减半,同时把MiniBatchSize调大。MiniBatchSize从 64 调到 128 之后,每个 epoch 的迭代次数减少,整体训练时间能显著下降。另一个技巧是用'ExecutionEnvironment', 'auto',如果机器有 GPU,MATLAB 会自动调用,不用改代码。
6.5 保存和部署模型时的坑
训练好的net对象直接用save保存,下次加载时要注意 MATLAB 版本一致性。更稳妥的方式是同时保存一份meta结构体,里面包括输入窗口长度、归一化参数、特征名称等。
save('lstm_transformer_model.mat', 'net', 'meta');我习惯把推理流程封装成一个函数,部署或者给别人使用时只需调用它:
function yPred = predictWithModel(modelPath, newData) S = load(modelPath); winSize = S.meta.winSize; mu = S.meta.mu; sg = S.meta.sg; lastSeq = newData(end-winSize+1:end); lastSeq = (lastSeq - mu) / sg; yPred = predict(S.net, {lastSeq'}) * sg + mu; end这样不管前端是 GUI 还是 Web 服务,核心预测逻辑都只有几行,不容易出错。
做这个项目前后折腾了差不多一周,最深的体会是:混合模型的原理听上去高大上,但真正决定效果的往往是数据处理细节和训练配置,而不是模型结构本身。multiHeadSelfAttention内置层的加入让代码量省了一大截,重点可以放在超参数调优和 GUI 交互逻辑上。如果你准备从单 LSTM 迁移到这个结构,建议先在本子上把维度变化写一遍,再动手改代码,能省下不少调试时间。最后老规矩,觉得这期内容有用的话,点赞收藏关注走一波,后续我还会继续拆解更多时序预测的实战案例。