不用想得太玄乎,Transformer在时序预测这件事上,本质上就是一套更聪明的“找规律”机制。单变量时序预测,说白了就是给你一串历史数值,让你预测下一个时间点的数;而Transformer干的活,就是在这串数里面找出“哪些历史时刻对预测未来最重要”,并且不要求它们一个挨一个地紧挨着。这个特性在处理长序列时特别值钱,因为传统RNN/LSTM得按顺序一步步往后传信息,序列一长,前面的信息要么衰减、要么被后面淹没,而Transformer直接跳过这个限制,全局找依赖。
我在Matlab里把这套东西落地过好几轮,踩过不少坑。网上关于Transformer的教程十个里有八个是Python写的,Matlab的实战资料确实稀碎。这篇博文就按我自己在Matlab里摸出来的路子,把数据预处理、注意力机制实现、模型训练、多步预测这几个环节挨个拆开讲,偏向能直接跑的方案,也会把一些只会在实操中暴露出来的细节翻出来说。
1. 整体思路拆解:为什么在Matlab里用Transformer做单变量预测
1.1 单变量时序预测的核心需求
单变量时序预测,数据形态最常见的就是类似[t1, t2, t3, ..., tn]这样一列数值,采样间隔固定,比如每小时的电负荷、每天的股价收盘价、每分钟的服务器CPU使用率。目标是给出过去L个时刻的值,预测未来H个时刻的值。
这个任务听起来简单,但它对模型有个隐性要求:你给的窗口越长,模型越需要有选择地“遗忘”无关信息,同时保留关键信息。窗口短的时候,比如只看过去7天预测明天,线性模型或者LSTM都够用了;但一旦你希望看过去30天甚至90天的数据来预测未来7天,多数模型会陷入两类毛病:要么把早期信息当噪声忽略掉,要么被最近的几个波动带着跑。Transformer的注意力机制,恰好可以理解为让模型在每一个预测时刻“重新翻一遍历史记录”,按重要程度加权组合历史信息,而不是按顺序“硬背”下来。
1.2 为什么选Matlab而不是Python
我不是说Python不好,Python生态里Transformer时序预测的轮子确实多。但Matlab在几条线上有它独特的优势,尤其是工程落地和算法验证阶段:
第一,Matlab的timeseries、timetable以及fillmissing、resample这些原生函数在处理时间戳对齐、缺失值插值、异常值剔除方面非常顺手,代码量比Python的pandas链路短不少。
第二,Deep Learning Toolbox从R2021a开始原生支持Transformer层,包括transformerLayer、positionEmbeddingLayer,这意味着你不需要像在Python里那样手动拼MultiHeadAttention、LayerNormalization、FeedForward这些子层(当然手动拼也可以,后面我会讲怎么做)。
第三,Matlab在训练过程的可视化、结果分析与报表输出上,几乎是零成本。trainingProgressPlot打开,Loss曲线、RMSE曲线直接交互式查看,对于调参阶段的迭代效率加成很明显。
当然,我自己实际用下来的体会是:Matlab适合把“模型结构和训练流程”跑通,适合团队里不熟悉Python的工程师介入维护,但如果后面要做大规模并行训练或者部署到异构计算设备上,还是得转到Python生态去。不过这正是“实战指南”该干的事——先让你在Matlab里把算法链路和实验验证做完,再考虑工程化的事情。
1.3 模型架构选型的取舍
用Transformer做时序预测,架构上有一个需要提前拿主意的分叉口:用Encoder-only、Decoder-only还是Encoder-Decoder。
在单变量短序列预测场景里,我最常用的是Encoder-only架构加一个回归头,具体来说就是输入一段历史窗口,通过Transformer的Encoder层提取特征,然后接一个全连接层直接输出未来若干个时间步的值。这个结构最简洁,训练也相对稳定。Encoder-Decoder结构在这种“用过去一段预测未来一段”的任务里也能用,但训练难度高一些,而且对数据量的要求更大,数据量小于几千条时容易过拟合。
还有一个选择是用Matlab内置的transformerLayer还是自己搭multiheadAttention。transformerLayer是R2021a之后MathWorks封装好的,参数少,用起来非常简单;但问题是它把多头注意力和前馈网络打包在一起,你想改内部细节(比如加个残差开关、换注意力掩码)就麻烦。自己搭的好处是灵活,适合你需要自定义注意力权重的场景(比如屏蔽未来时刻信息的因果掩码)。这篇实战指南里,我会优先用transformerLayer搭配positionEmbeddingLayer,因为代码量最小,跑通率最高;自定义注意力部分,我单独写一种方案供进阶参考。
2. 数据准备与预处理:预测效果的第一道分水岭
2.1 数据形态判断与清洗
拿到原始单变量序列后,第一步不是急着喂给模型,而是先做三件事:检查缺失值、确认时间间隔是否均匀、找出明显异常点。
缺失值处理上,Matlab的fillmissing函数非常方便。如果缺失值前后趋势明显,用spline插值效果不错;如果序列本身就是高噪声的传感器数据,用movmedian(滑动中位数)更稳,不容易被单点异常带偏。我一般会先画一下原始曲线,肉眼判断噪声量级,再决定清洗策略。
时间间隔均匀性容易被忽略。很多实际采集的数据,比如来自数据库记录的事件,时间间隔并不完全均匀;如果直接把不等间隔的数据当成等间隔序列输入模型,时间信息就是扭曲的。稳妥做法是用retime先把数据重采样到一个固定步长。注意,重采样方向是“压缩”还是“扩展”会影响插值方式:降采样时用聚合函数(比如取小时均值),升采样时用插值。
异常值方面,我常用的一个粗筛手段是:计算序列的滑动均值和滑动标准差,凡是超出均值 ± 5倍标准差的点,先用线性插值覆盖,后续再做进一步分析。这不是什么高深方法,但对大多数工况数据足够有效。
2.2 归一化:不同量纲特征的关键处理
Transformer里的注意力机制本质上是计算Query和Key之间的点积,点积的大小受向量模长影响很大。如果你输入的数据范围是几万量级的,和模型里初始化的小数值参数相乘后,softmax很容易饱和,导致梯度极小,训练半天不收敛。所以输入数据必须先做归一化。
对于单变量时序预测,我推荐最朴素的Min-Max归一化: [ x' = \frac{x - x_{\min}}{x_{\max} - x_{\min}} ] 把数据压到[0, 1]区间。实际使用时,x_{\min}和x_{\max}最好只从训练集部分计算,然后把验证集、测试集都用这同一组参数做变换。这样做的原因是:如果直接用全量数据的最大最小值,相当于让模型在训练时就“偷看”了未来数据的分布范围,测试时的评估结果会虚高,部署到真实环境时如果遇到超出范围的值,预测就会出现偏差。
我自己经常在Matlab里这样写:
% 假设data是原始序列,trainNum是训练集长度 trainData = data(1:trainNum); xmin = min(trainData); xmax = max(trainData); dataNorm = (data - xmin) / (xmax - xmin);测试结束后要还原预测结果时,用pred = predNorm * (xmax - xmin) + xmin即可。
2.3 滑动窗口构建训练样本
Transformer本身不“记忆”长期信息,它看到的是你喂进去的一段固定长度的窗口。所以需要把原始的连续序列切分成“输入窗口 + 预测标签”的样本对。
窗口长度L的选择是个关键权衡:
- 窗口太短(比如只有10个点),模型能看到的历史信息太少,遇到有明显周期性的序列时,很难捕捉到完整的周期模式。
- 窗口太长(比如500个点),计算量和内存消耗会显著上升,而且不一定是好事——如果序列的周期是24小时,窗口超过两三个周期以后,多出来的信息大多是重复的周期模式,边际收益很低。
我惯用的经验值:先做序列的自相关分析,找到最显著的周期长度T,然后让L至少覆盖2T~3T。比如小时级的电力负荷数据,周期明显是24小时,那窗口设在48~72就比较合适,既能看到至少两个完整周期,又不会让计算量爆炸。
训练样本的构建,用floor和循环就可以完成:
function [XTrain, YTrain] = makeSamples(dataNorm, L, H) % dataNorm: 归一化后的列向量 % L: 输入窗口长度 % H: 预测步数 N = length(dataNorm); numSamples = N - L - H + 1; XTrain = zeros(L, 1, 1, numSamples); % 按图像格式 [高度, 宽度, 通道, 样本数] YTrain = zeros(H, numSamples); for i = 1:numSamples XTrain(:, :, 1, i) = dataNorm(i : i+L-1); YTrain(:, i) = dataNorm(i+L : i+L+H-1); end end注意我这里的X训练维度是[L, 1, 1, numSamples],这对应Matlab Deep Learning Toolbox里序列作为单通道图像输入的标准格式。很多人刚接触时容易把维度搞错,后面模型输入层报错就是从这里开始的。
3. Matlab中Transformer模型的两种搭建路径
3.1 路径一:用内置Transformer层快速搭建(推荐新手)
从R2021a起,Deep Learning Toolbox提供了transformerLayer,用法很直白。一个典型的Encoder-only回归模型,layer图大致长这样:
% 关键超参数 numHeads = 4; % 注意力头数 numLayers = 2; % Encoder层数 hiddenDim = 64; % 特征维度 ffDim = 128; % 前馈网络维度 dropoutProb = 0.1; L = 72; % 输入窗口长度 H = 12; % 预测步长 layers = [ sequenceInputLayer(1, 'Name', 'in') % 单变量输入 positionEmbeddingLayer(hiddenDim, L, 'Name', 'pos') % 位置编码,长度设为L transformerLayer(hiddenDim, numHeads, 'NumHead', numHeads, ... 'NumFFN', ffDim, 'DropoutProb', dropoutProb, 'Name', 'tf1') transformerLayer(hiddenDim, numHeads, 'NumHead', numHeads, ... 'NumFFN', ffDim, 'DropoutProb', dropoutProb, 'Name', 'tf2') globalAveragePooling1dLayer('Name', 'gap') % 对时间维做全局平均池化 fullyConnectedLayer(H, 'Name', 'fc_out') % 直接输出未来H个值 regressionLayer('Name', 'out') ]; lgraph = layerGraph(layers);这里有几个容易踩坑的细节:
positionEmbeddingLayer的第二个输入参数SequenceLength必须和你喂进去的序列长度L一致。如果你后面预测时想换窗口长度,这个层就得重建,模型就得重新训练。transformerLayer默认会做残差连接和层归一化,所以你不需要像在Python里那样手动加addLayer和layerNormalizationLayer。globalAveragePooling1dLayer把[hiddenDim, L]的特征压缩成[hiddenDim, 1],再接全连接层输出。这一步是为了去掉时间维度,让全连接层直接映射到预测步数。
训练选项用常见的配置即可:
options = trainingOptions('adam', ... 'MaxEpochs', 100, ... 'MiniBatchSize', 64, ... 'InitialLearnRate', 1e-3, ... 'LearnRateSchedule', 'piecewise', ... 'LearnRateDropFactor', 0.5, ... 'LearnRateDropPeriod', 20, ... 'GradientThreshold', 1, ... 'Shuffle', 'every-epoch', ... 'ValidationData', {XVal, YVal}, ... 'ValidationFrequency', 20, ... 'Plots', 'training-progress', ... 'Verbose', true);GradientThreshold设为1这个细节值得说一下:Transformer训练时梯度范数偶尔会突然飙高,如果不截断,训练Loss会出现非常明显的尖峰甚至变成NaN。设了梯度截断后,训练稳定度会高一大截。
3.2 路径二:自定义多头注意力层(进阶可控)
内置层够用,但如果你需要对注意力做定制化修改(比如做因果掩码、修改注意力分数的计算方式),那就得走自定义层路线。Matlab里用MultiHeadAttention这个函数其实也能凑合,但它是给transformerLayer内部用的,直接调用时和自定义训练循环配合不好。我自己的做法是用dlnetwork配合modelFunction手写一个精简版注意力层。
核心的多头注意力部分,简化实现大致是:
function Z = multiHeadAttention(Q, K, V, numHeads) % Q, K, V: [featureDim, seqLen, batchSize] [d, n, m] = size(K); headDim = d / numHeads; % 分头 Q = reshape(Q, headDim, numHeads, n, m); K = reshape(K, headDim, numHeads, n, m); V = reshape(V, headDim, numHeads, n, m); % 注意力分数 scores = pagemtimes(permute(Q, [1 3 2 4]), permute(K, [1 3 4 2])); % 需要核对维度顺序 scores = scores / sqrt(headDim); weights = softmax(scores, 'DataFormat', 'SSTB'); Z = pagemtimes(weights, permute(V, [1 3 2 4])); Z = reshape(Z, d, n, m); end这里涉及Matlab R2020b及以后版本的pagemtimes,在批处理矩阵乘法上效率不错。但说实话,新手阶段我不太建议一上来就手写注意力。原因很简单:一旦某个维度顺序写错,报错信息提示得不够直观,排查时间可能比训练时间还长。先用内置层验证思路,确有定制需求再切换自定义实现,是更平滑的学习曲线。
3.3 两种路径的对比
| 对比项 | 内置transformerLayer | 自定义multiHeadAttention |
|---|---|---|
| 代码量 | 少,约10行 | 多,约80行以上 |
| 训练稳定性 | 高,官方封装了残差和归一化 | 取决于实现细节 |
| 可定制性 | 低,内部结构不可改 | 高,掩码、分数计算均可改 |
| 调试难度 | 低 | 较高,维度容易出错 |
| 适合场景 | 快速验证、标准预测任务 | 研究型改进、非对称注意力等 |
4. 训练过程与预测实现:完整流程细解
4.1 训练集、验证集、测试集的切分
时序数据的切分必须按时间顺序,不能随机打乱。我的切分习惯是:前70%做训练集,中间15%做验证集,最后15%做测试集。
验证集和训练集之间要留一段“缓冲区”,避免验证集样本与训练集窗口有重叠。举例说,如果窗口长度L=72,训练集最后72个点会与验证集最前面的样本输入窗口重叠,这样验证集的评估结果就偏乐观了。所以我一般在训练集和验证集之间砍掉L个点,验证集和测试集之间也砍掉L个点。
这个细节很小,但直接影响你判断模型是否过拟合、何时早停。如果你发现验证集Loss比预期低很多,先怀疑是不是数据重叠导致的信息泄漏。
4.2 训练监控:Loss曲线怎么看
训练开始后,training-progress绘图窗口会显示两条Loss曲线:一条是训练Loss,一条是验证Loss。我一般是这样阅读的:
- 训练Loss持续下降,验证Loss也在下降:健康状态,继续训。
- 训练Loss下降,验证Loss先降后升:过拟合已开始,应该往回退到验证Loss最低的那个epoch,或者马上调大
DropoutProb。 - 训练Loss不怎么降:学习率可能偏大导致震荡,或者归一化出了问题,先回头看数据。
- 两条Loss都变成NaN:最常见原因是梯度爆炸,检查
GradientThreshold是否开启,以及学习率是不是设置得过高。
Matlab训练过程的一个小坑是:ValidationFrequency设得太小时,验证集评估非常频繁,长序列数据上每个epoch都要额外消耗不少时间。我的经验值是每个epoch验证一次就够,即ValidationFrequency设为floor(训练样本数 / MiniBatchSize)。
4.3 单步预测与多步预测的实现方式
模型训练完成后,预测阶段有两种场景:
场景一:单步滚动预测。用最新的L个真实观测值预测下一个点,得到预测值后,把它拼到序列末尾,丢掉最早的一个点,再用这个新窗口预测下下个点。这种方式的优点是每步都用了真实数据做校正,累积误差小;缺点是只能一步一步来,无法一次性得到未来H步的曲线。
Matlab代码示例:
pred = zeros(H, 1); currentWindow = dataNorm(end-L+1 : end); % 最后L个观测值 for t = 1:H % 注意维度:currentWindow是[L,1],需要变成[L,1,1,1] x = reshape(currentWindow, [L, 1, 1, 1]); yPred = predict(net, x); pred(t) = yPred(1); % 取第一个预测值 currentWindow = [currentWindow(2:end); yPred(1)]; end pred = pred * (xmax - xmin) + xmin; % 反归一化场景二:一步到位多步预测。模型直接输出H个值。这种方式更高效,但每一路的预测误差在训练时是独立优化的,实际使用时如果中间某个时间步预测偏了,后续步并不会通过“看到偏差点”来修正自己。所以,对于波动较大的数据,我通常宁可用单步滚动预测,也不直接用模型一次性吐H个值。
作为折中,可以用“分块预测”策略:把H拆成几段,比如预测未来12小时,每次模型只预测4小时,然后拿这4小时的真实观测值(如果等得到)或预测值(如果等不到)作为下一段的新输入。在验证集上测试时,如果你用的是历史数据,可以用真实值滚动;在未来预测场景,只能用预测值滚动,两套流程要分开设计。
4.4 误差评估与结果可视化
预测完成后,我习惯同时算三组指标:
- MAE(平均绝对误差):直观,工程上最好解释。
- RMSE(均方根误差):放大较大误差的惩罚,适合评估模型是否在某些时刻出现明显偏离。
- MAPE(平均绝对百分比误差):适合评估相对误差水平,但要注意序列中有接近0的值时MAPE会变得极大,这时改用
sMAPE更稳。
在Matlab里算这几项非常快速:
MAE = mean(abs(yTrue - yPred)); RMSE = sqrt(mean((yTrue - yPred).^2)); MAPE = mean(abs((yTrue - yPred) ./ yTrue)) * 100;可视化上,我强烈建议把“真实值曲线、预测值曲线、误差带(比如±2倍标准差)”画在同一张图上,而不是只画两条线。误差带能让读者一眼看出模型在哪些时段是不自信的。这不仅是给自己看的,也是给别人汇报结果时最有说服力的图。
5. 关键参数调优:从“能跑”到“好用”
5.1 窗口长度L与预测步长H的配合
窗口长度和预测步长的关系,我理解为一个“视野-分辨率”的权衡。预测步长越长,每一步的不确定性越大,需要的历史视野也越宽;但视野越宽,窗口越长,训练样本数越少,模型可能陷入过拟合并且训练变慢。
我在电力负荷数据集上的实验结果可以参考:数据是2小时间隔的负荷记录,周期长度为12个点,预测未来6个点(即12小时)。我用L=24、L=36、L=48做对比,结果L=36效果最好,L=48虽然有更长的历史信息,但因为训练样本变少,RMSE反而上升了约4%。所以我的建议是:先用自相关图找主导周期,把L设为2~3倍周期,然后做一组简单的网格搜索(比如L=[1.5T, 2T, 3T, 4T]),每组训练不超过50个epoch,快速对比验证Loss,再选最终值。
5.2 注意力头数与层数:不是越多越好
numHeads的设置,很多人的直觉是“越多越好”,其实不然。多头注意力的目的是让模型从不同子空间学习不同模式,但头数太多时,每个头分到的特征维度太小(headDim = hiddenDim / numHeads),单个头能建模的模式复杂度反而降低。对于隐藏维度hiddenDim=64的情况,把numHeads设成4或8都比较合适;设成16时,每个头只剩4维特征,效果实测会下降。
层数上,时序数据通常不需要像NLP里BERT那样堆12层。numLayers=2到numLayers=4在大多数单变量任务里已经足够。堆太多层不仅训练慢,还容易出现“退化”问题——训练Loss降不下去,验证Loss反而更高。我自己的经验是:先试numLayers=2,如果训练Loss下降得太慢,再考虑加到3层或4层;中间检查一下各层的输出特征的分布情况,如果某一层输出开始大量集中在0附近,说明这一层可能没有学到有效信息。
5.3 学习率与训练轮数的协同调整
Transformer训练对学习率比较敏感。learnRate=1e-3配合Adam通常是个不错的起点,但不一定最优。如果训练Loss下降太慢,可以试试把InitialLearnRate调到3e-3,同时开启LearnRateDropFactor=0.5,每20个epoch降一次;如果Loss开始震荡,那就把学习率调回1e-3或更低。
训练轮数上,我不建议一开始就设一个很大的MaxEpochs死等。更高效的做法是:MaxEpochs=100,同时观察验证Loss曲线,当验证Loss连续10~20个epoch不再下降时,手动停止训练,用最低验证Loss对应的模型权重。这里提一个Matlab的小技巧:在trainNetwork过程中,你可以通过OutputFcn回调函数在每个epoch结束后保存当前模型,这样即使训练中断,也有最新的权重可用。
5.4 正则化与防过拟合策略
单变量时序数据量通常不大(几千到几万条),Transformer又是一个参数很多的模型,过拟合风险是真实存在的。我常用的防过拟合手段按优先级排序:
- Dropout:
transformerLayer内置的DropoutProb建议在0.1~0.3之间调。数据量小就偏高一点,数据量大可以偏低。 - 早停(Early Stopping):观察验证Loss,一旦开始回升就停。
- 权重衰减(L2正则化):
trainingOptions里的L2Regularization,默认1e-4,数据量小时可以调到1e-3。 - 数据增强:对时序数据可以做“窗口抖动”或“小幅噪声注入”。这个方法我一开始觉得有点玄学,后来试了一下,针对强噪声的传感器数据,确实能提升泛化性。具体做法是在训练样本的输入窗口上加上均值为0、标准差为数据标准差的5%~10%的高斯噪声。
6. 实战案例:Matlab完整代码与流程演示
6.1 案例数据说明与加载
我用一个公开的电力负荷数据集做演示,数据格式是每2小时一条负荷记录,共1000条。这个数据有显著的周期性(每12个点为一个小周期),也有一定的随机波动,非常适合演示单变量时序预测。
Matlab加载并查看数据概貌:
% 假设数据存储在loadData.mat中,变量名为loadSeries load('loadData.mat'); whos loadSeries figure; plot(loadSeries); title('原始负荷序列'); xlabel('时间点'); ylabel('负荷值');画图后先观察有没有明显的缺失段或者尖峰。看起来整体平稳、没有缺失值,就可以继续了。
6.2 预处理与样本构建完整流程
rng(42); % 固定随机种子,保证结果可复现 % 参数设定 L = 36; % 输入窗口长度 H = 6; % 预测步长 numTrain = 700; % 训练集长度 numVal = 150; % 验证集长度 numTest = length(loadSeries) - numTrain - numVal - 2*L; % 测试集长度 % 归一化 trainRaw = loadSeries(1:numTrain); xmin = min(trainRaw); xmax = max(trainRaw); dataNorm = (loadSeries - xmin) / (xmax - xmin); % 构建训练、验证、测试样本(注意切分时预留缓冲区) trainEnd = numTrain - L; X = zeros(L, 1, 1, trainEnd); Y = zeros(H, trainEnd); for i = 1:trainEnd X(:, :, 1, i) = dataNorm(i : i+L-1); Y(:, i) = dataNorm(i+L : i+L+H-1); end % 验证集和测试集的切分类似,但要注意起始索引向后偏移 valStart = numTrain + L; valEnd = valStart + numVal - L - H; XVal = zeros(L, 1, 1, valEnd - valStart + 1); YVal = zeros(H, valEnd - valStart + 1); for i = valStart:valEnd XVal(:, :, 1, i - valStart + 1) = dataNorm(i : i+L-1); YVal(:, i - valStart + 1) = dataNorm(i+L : i+L+H-1); end这段代码看起来有点繁琐,但每一步都是在处理“窗口重叠”和“序列边界”的问题。我在第一次写这段代码时就是因为索引算错,导致训练样本和验证样本高度重叠,模型评估结果虚高了不少。
6.3 模型定义与训练执行
模型定义沿用前面3.1节的layerGraph。完整训练代码大致是:
layers = [ sequenceInputLayer(1, 'Name', 'in') positionEmbeddingLayer(hiddenDim, L, 'Name', 'pos') transformerLayer(hiddenDim, numHeads, 'NumHead', numHeads, ... 'NumFFN', ffDim, 'DropoutProb', dropoutProb, 'Name', 'tf1') transformerLayer(hiddenDim, numHeads, 'NumHead', numHeads, ... 'NumFFN', ffDim, 'DropoutProb', dropoutProb, 'Name', 'tf2') globalAveragePooling1dLayer('Name', 'gap') fullyConnectedLayer(H, 'Name', 'fc') regressionLayer('Name', 'out') ]; options = trainingOptions('adam', ... 'MaxEpochs', 80, ... 'MiniBatchSize', 32, ... 'InitialLearnRate', 1e-3, ... 'LearnRateSchedule', 'piecewise', ... 'LearnRateDropFactor', 0.5, ... 'LearnRateDropPeriod', 25, ... 'GradientThreshold', 1, ... 'Shuffle', 'every-epoch', ... 'ValidationData', {XVal, YVal}, ... 'ValidationFrequency', floor(trainEnd / 32), ... 'Plots', 'training-progress', ... 'Verbose', false); net = trainNetwork(X, Y, layers, options);这里有一个比较隐蔽的坑:MiniBatchSize如果设置成32,但训练样本总数不是32的整数倍,最后一个batch会自动截断,这没问题;但如果样本太少(比如少于一个batch),就会报错。所以训练样本数最好在几百以上,这也意味着窗口长度和训练集长度要匹配好。
6.4 模型预测与结果还原
训练完成后,在测试集上做滚动预测:
testStart = valEnd + 1; numTest = length(loadSeries) - testStart - H + 1; predAll = zeros(H, numTest); trueAll = zeros(H, numTest); for i = 1:numTest idx = testStart + i - 1; xInput = dataNorm(idx : idx+L-1); xInput = reshape(xInput, [L, 1, 1, 1]); yPred = predict(net, xInput); predAll(:, i) = yPred; trueAll(:, i) = dataNorm(idx+L : idx+L+H-1); end % 还原到原始量纲 predRaw = predAll * (xmax - xmin) + xmin; trueRaw = trueAll * (xmax - xmin) + xmin;这里我一次性预测了测试集每段的H步输出,并且每段都用真实历史窗口做输入。这种评估方式对应的是“给定历史,预测未来一段”的离线评估模式,反映的是模型在理想条件下的上限性能。如果你要模拟在线部署环境,需要在循环里用预测值滚动更新输入窗口,注意区分这两者。
6.5 结果指标与图表
MAE = mean(abs(trueRaw - predRaw), 'all'); RMSE = sqrt(mean((trueRaw - predRaw).^2, 'all')); MAPE = mean(abs((trueRaw - predRaw) ./ trueRaw), 'all') * 100; fprintf('MAE: %.4f\nRMSE: %.4f\nMAPE: %.2f%%\n', MAE, RMSE, MAPE); % 画第一个预测窗口的结果 figure; tAxis = 1:H; plot(tAxis, trueRaw(:, 1), '-o', 'LineWidth', 1.5); hold on; plot(tAxis, predRaw(:, 1), '-x', 'LineWidth', 1.5); legend('真实值', '预测值'); xlabel('未来时刻'); ylabel('负荷值'); title('第一个测试窗口的未来6步预测');画图这一步看似简单,但非常必要。只看数值指标,你无法直观感受到预测曲线和真实曲线的贴合程度——到底是对趋势预测得很好、只是幅度偏小,还是整体相位就错了,这两种情况MAE差不多,但改进方向完全不一样。趋势和相位错位,说明位置编码或者时间依赖建模有问题,需要调窗口长度;幅度偏小,可能是归一化方式或者损失函数造成的。
7. 常见问题与排查技巧实录
7.1 训练Loss不降反升,怎么办
我遇到这个问题的次数不少,尤其是第一次在Matlab里搭Transformer时。排查顺序如下:
- 检查归一化是否出错,特别是训练集和验证集是否用了不同的
xmin、xmax。 - 检查是否设了
GradientThreshold,如果没有,先加上,设成1。 - 调低学习率,比如从1e-3降到3e-4,看是否缓解。
- 检查数据里是否有极端异常值。即便归一化过,如果一个点特别大,压缩到[0,1]后其他正常数据都挤在一小块区域,模型等于在学一个被“压扁”的信号,很难收敛。这种情况要对极端值单独处理,比如用分位数截断。
7.2 预测结果整体滞后一拍,是模型的问题吗
预测曲线贴着真实曲线,但整体滞后一个时间步,这是时序预测里非常经典的现象。本质原因是模型学到的“最优策略”就是复制最近一个观测值。因为对于平滑变化的序列,下一时刻的值和当前时刻的值相关度极高,用“复制”作为预测,从损失函数角度看已经很低了。
这不算模型坏了,但说明模型还没有真正学到序列的周期性模式。我的处理办法:
- 增大窗口长度,让模型看到更多历史样本,从而发现周期性而不是依赖短时相关性。
- 对损失函数做针对性修改,比如在回归损失之外,加一个“差分损失”项,惩罚预测值和真实值在变化趋势上的不匹配: [ \mathcal{L} = \mathcal{L}{MSE} + \lambda \cdot \mathcal{L}{MSE}(\Delta y_{pred}, \Delta y_{true}) ] 其中(\Delta y_t = y_t - y_{t-1})。这样模型就不能靠简单复制混过去了。
7.3 验证集效果好,测试集效果差
这是“过拟合验证集”的经典症状。常见原因是验证集在训练过程中被反复用于判断是否早停、是否调参,本质上模型已经“见”了很多次验证集,验证集不再是一个公平的评估集。所以我的习惯是:调参阶段只用训练集和验证集,测试集留到所有参数确定之后,只跑一次,得出最终数字。如果测试集效果不如验证集很多,那大概率是泛化能力不足,而不是评估流程出问题。
7.4 Matlab版本兼容性
Transformer相关的层函数(transformerLayer、positionEmbeddingLayer)在R2021a才正式发布,更早的版本会报“未定义函数”错误。如果你的Matlab版本较旧,有两个选择:
- 升级到R2021a以上(推荐,省心)。
- 手动实现Transformer层,就是前面3.2节的自定义方案。老版本也支持
multiheadAttention这个函数(R2020b引入了相关支持),但整体要自己组装。
另外一个兼容性细节是:pagemtimes在R2020b引入,如果自定义注意力层里用到它,版本太老也会报错。检查版本最直接的办法是在命令行敲ver,看Deep Learning Toolbox的版本号。
7.5 训练速度太慢,有什么优化手段
在CPU上训Transformer确实痛苦。如果你的显卡是NVIDIA的,并且算力在3.5以上,Matlab的trainNetwork会自动调用GPU。可以用gpuDevice检查GPU是否可用。
如果实在没有GPU,可以这样缩减计算量:
- 减小
hiddenDim,从64降到32。 - 减小
MiniBatchSize,从64降到16。 - 减小窗口长度
L。 - 少堆层数,从2层降到1层。
- 用
'Plots', 'none'关掉实时绘图,能节省一部分开销。
我自己就试过在纯CPU环境下用L=72、hiddenDim=128训练一个单变量模型,一个epoch要跑三分钟,训80个epoch要四个小时,后来降到hiddenDim=64、L=48,一个epoch只要四十秒,效果差异并不大。很多时候模型的“够用”和“复杂”之间,收益曲线是很平缓的。
7.6 预测未来多步时,误差越来越大
多步预测的累积误差是必然的,但可以通过策略降低:
- 在线预测时,每个时间步都尝试获取真实观测值来更新窗口。比如你预测的是未来6小时,但每小时都能拿到真实值,那就每1小时滚动一次,而不是等6小时后再校正。
- 如果只能一次性预测未来6小时,那就接受误差放大,但可以输出置信区间让使用者知道哪些段预测可信度高、哪些段可信度低。一个简单做法是mini-ensemble:用不同随机种子训3个模型,对同一个输入分别预测,预测均值作为最终结果,预测方差作为置信区间宽度。这个方法在Matlab里实现成本很低,只是训练时间乘以3,但对于工程汇报非常有价值。
8. 从验证到落地:Matlab模型部署与扩展
8.1 导出模型与集成到现有系统
如果你是在做项目,模型验证完之后面临部署问题。Matlab提供两个方向:
方向一:在Matlab环境内部署。把训练好的网络保存为.mat文件,后续直接load进来并写一个预测函数封装。适合在Matlab内部做后续数据接入和结果展示的场景。
方向二:生成独立可执行程序或C/C++代码。用codegen结合MATLAB Coder,把预测函数转成C代码,然后集成到上位机、嵌入式设备或服务器中。这一步比较硬核,需要确保预测函数里用的所有操作符都支持代码生成。有一点需要提前注意:trainNetwork训练出来的SeriesNetwork或DAGNetwork对象支持codegen,但如果你用了自定义层,代码生成支持就取决于自定义层的写法。所以如果项目有部署规划,尽量在模型设计阶段就优先使用内置层。
8.2 从单变量扩展到多变量
单变量建模是理解Transformer的好起点,实际工程里更常见的是多变量时序预测——比如除了负荷值,还有温度、湿度、节假日标记等外部变量。扩展方法是把sequenceInputLayer(1)改成sequenceInputLayer(numFeatures),输入数据的维度从[L, 1, 1, numSamples]变成[L, numFeatures, 1, numSamples]。但要注意几点:
- 多变量输入的各个特征需要分别做归一化,因为量纲不同,且各自的最大最小值不同。
- 外部变量如果含有未来已知值(比如天气预报),可以拼到解码器输入中;如果只有历史值,就只能走Encoder路径。
- 位置编码的维度
hiddenDim要和输入特征数解耦,因为特征数经过一个输入投影层后才会映射到hiddenDim。如果直接用sequenceInputLayer(numFeatures)接positionEmbeddingLayer(hiddenDim, L),中间可能需要一个fullyConnectedLayer(hiddenDim)做维度对齐。
8.3 与LSTM、TCN等模型的对比实验设计
实话说,Transformer不一定在所有单变量数据上都优于LSTM、TCN这些轻量模型。小型数据量(几千条)、强周期性、无明显长程依赖的任务里,LSTM往往就够用,而且训练快、调参简单。Transformer的优势主要在长序列、复杂依赖的场景。
所以在项目里,我通常不会直接默认“Transformer最好”,而是会设计一组基线对比:LSTM、TCN、Transformer各训一版,用相同的训练集、相同的归一化、相同的窗口长度,对比验证集和测试集指标。这组实验做下来,你才能真正回答“这个任务值不值得上Transformer”这个问题。Matlab里这几种模型的搭建都很方便,做一个对比表发到报告里,领导看了也觉得专业。
8.4 后续可以尝试的改进方向
如果你已经跑通了基础的Transformer单变量预测,有几个明确的升级方向:
- 加入外部特征:多变量输入能显著提升预测精度,尤其是在强外部因素影响的数据上。
- 分位数回归:把输出层从
regressionLayer改成自定义分位数损失,输出预测区间,而不仅仅是点预测。 - 概率预测:结合一个输出分布层,比如用
varcount或自定义的高斯分布层,输出均值与方差。 - 跨序列迁移:如果有多条相似序列(比如多个用户的负荷曲线),先在大数据上预训练,再针对单个序列微调,效果往往比单独训每条序列好很多。
这些方向每一步都可以单独写一篇长文,但这篇指南先把“从零到一跑通Transformer单变量预测”这件事做到位。你把这套流程走通之后,再谈扩展,会顺手很多。
根据我自己的项目经验,Matlab做Transformer时序预测,最大的障碍不是模型本身,而是数据准备和评估流程这些“看不见的工程细节”。很多人模型跑不出来,80%的问题出在数据切片、归一化、维度顺序这些地方,真正跟Transformer公式较劲的时候反而不多。所以这篇里我把这些地方反反复复强调了多遍,就是因为这些琐碎步骤才是实战里最容易绊倒人的地方。希望这份指南能帮你少走几步弯路,把Transformer这块硬骨头啃下来,真正用到自己的数据上。