简介:这是基于CNN-LSTM-Attention、以Matlab实现的多变量时间序列多步预测完整项目实例,面向对深度学习和时序预测有一定基础的研究人员、工程师和数据科学家,旨在解决传统时间序列模型在复杂非线性与长时间依赖上的不足。压缩包仅含1个docx文档,约40KB,但内容覆盖面广:从项目背景、目标、挑战、创新点,到金融、能源、气象、医疗健康、交通等应用领域均有阐述。模型部分详细拆解CNN模块特征提取、LSTM模块时间序列建模、Attention模块加权重要时间步和输出层预测,并给出模型构建、编译、训练与评估的完整Matlab示例代码。文档条理清晰,读者可按模块理解后再上手实践。目前已有99人学习。通过该实例,读者能掌握CNN-LSTM-Attention的Matlab实现思路,学习如何提升多步预测精度与模型泛化能力,减少误差累积和过拟合;文档还提供了模块协同分析,便于迁移到自身研究或工程项目中。
1. 多变量多步预测用CNN-LSTM-Attention:先搞清楚它在解决什么问题
如果你用Matlab做过气象、负荷或交通流这类多变量时间序列预测,大概率遇到过这种尴尬:单步预测效果不错,一拉到未来6步、12步,曲线就慢慢变成一条平线。这个问题不是LSTM不够深,而是你把“多步预测”当成了“多输出回归”在硬做。CNN-LSTM-Attention这个组合之所以在多变量多步预测里被反复验证,是因为它把三个环节拆开了:CNN负责从多个变量里提取局部特征,LSTM负责把时间顺序记下来,Attention负责在你需要预测未来多个时刻时,决定到底该重点看历史里的哪几段。这篇笔记我按一个能在Matlab里完整跑通的项目实例来写,从数据构造、网络搭建到训练避坑,给你一套可以直接抄的落地路径,适合正在做深度学习实战项目案例、又不想切到Python环境的人。
2. 多步预测的模型选型:CNN-LSTM-Attention各管哪一段,策略怎么定
2.1 多变量预测的三个子问题:特征、依赖、输出步长
多变量时间序列预测,本质上要解决三个问题。第一是特征提取:你手里的多个变量往往不是独立变化的,温度、湿度、风速对同一个目标都有影响,但它们和目标的关联是局部的、非线性的。第二是时间依赖:过去24小时的数据里,哪些历史时刻对当前预测最重要,这个依赖长度可能远超一个LSTM的短期记忆。第三是输出步长:你要预测未来1到6步,这6个输出不是互相独立的,后一步的误差会直接影响前一步的可信度。
CNN-LSTM-Attention这个结构正好逐个对应。CNN用一维卷积在时间轴上扫过多个变量,相当于在每一小段窗口里做特征融合;LSTM把这些特征按时间顺序串起来,捕捉长期依赖;Attention在LSTM输出的每一个时间步上计算权重,告诉模型“未来第3步的预测,主要看历史第8步和第15步附近”。所以它不是三个模型简单拼接,而是把多步预测拆成了特征、记忆、聚焦三个环节。
我在Matlab里做这类项目时,最常用的落地方式不是让LSTM一步一步递归输出,而是让网络直接输出未来H个时刻的预测值。这种方式训练稳定、误差不累积,后面第4章的示例代码就是这么搭的。
2.2 三种多步预测策略:递归、直接、序列到序列
多步预测的策略选择,直接影响网络结构和训练方式。很多人一上来就写“预测一步,然后把预测值拼回去当输入”,这在Matlab里跑通很容易,但实际效果经常会翻车,因为训练时用的是真实历史值,预测时用的是模型自己的输出,两者分布不一致,误差会越滚越大。
常见做法是把多步预测分成三类,我在选型时一般按这张表来定:
| 策略 | 训练方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 递归预测 | 训练单步模型,预测时把上一步输出作为下一步输入 | 模型简单,参数少 | 误差累积严重,越往后越偏 | 依赖很短的序列,如1至2步 |
| 直接多步 | 为每个预测步长训练一个独立模型 | 每步模型独立,误差不累积 | 训练成本高,模型之间不一致 | 步长不多且各步规律差异大 |
| 序列到序列 | 编码器读历史,解码器逐步输出,可配合注意力 | 能建模步长间的依赖 | 训练复杂,需要teacher forcing | 长序列、长步长,如24步以上 |
我在这篇项目实例里选择的是“直接多步”的变体:编码部分用CNN加LSTM,Attention对编码后的时间步加权,最后用一个全连接层同时输出未来H个时刻。这样做的好处是训练只需要一次反向传播,预测时不需要递归,Matlab的trainNetwork可以直接处理,避免了递归策略里“训练和推理不一致”这个最常见的坑。
2.3 Attention放在LSTM后面:它的作用位置决定了模型能学到什么
Attention在这类结构里的位置不是随便放的。放在CNN后面,它只能对局部特征做加权,学不到时间顺序;放在LSTM前面,它加权的是原始输入,LSTM还是不知道该重点记什么。最常见的做法是放在LSTM输出之后,对LSTM每一个时间步的隐状态做加权求和。
具体机制可以这样理解:LSTM处理完一个长度为T的输入序列后,会输出T个隐状态,每个隐状态对应一个历史时刻的“总结”。Attention层给这T个隐状态各算一个权重,然后按权重加权求和,得到一个固定长度的向量,再送给全连接层做多步输出。权重大的历史时刻对预测贡献大,权重小的地方基本被忽略。
Matlab里实现这个机制,需要写一个自定义层。Deep Learning Toolbox没有直接提供“对LSTM所有时间步做加权求和”的现成层,这个点很多人会卡住,第4章我会给出一个完整的自定义Attention层代码,包含前向和反向实现。
3. 先练数据:多变量滑窗样本构造、归一化与训练/验证切分
3.1 滑窗构造训练样本:多变量序列怎么变成“输入-输出”对
Matlab里做时间序列预测,最常用的输入格式是cell数组:每个样本是一个 numFeatures × windowSize 的矩阵,行是变量,列是时间步。这里特别提醒,行和列不要搞反,sequenceInputLayer默认接收的序列格式是“特征数 × 时间步数”,很多人第一次跑通后报维度错误,基本都是栽在这一行。
下面这个函数用滑窗把原始多变量数据切成训练样本,预测目标取第一列变量:
function [XTrain, YTrain] = makeSamples(data, numFeatures, windowSize, horizon) numSamples = size(data, 1) - windowSize - horizon + 1; XTrain = cell(numSamples, 1); YTrain = zeros(numSamples, horizon); for k = 1:numSamples % 每个样本:从k开始取windowSize行,转置后是 numFeatures × windowSize XTrain{k} = data(k:k+windowSize-1, :)'; % 目标:windowSize之后连续horizon步的第一列变量 YTrain(k, :) = data(k+windowSize:k+windowSize+horizon-1, 1)'; end end代码里有几个参数要解释清楚。windowSize是回看窗口长度,一般取你数据里最明显周期长度的1到2倍,比如小时级数据有日周期,windowSize取24或48;horizon是要预测的未来步数,示例里取6代表预测未来6个时刻。numFeatures是变量个数,如果data有3列,numFeatures就是3,滑窗落在这个区间内的所有列都会被送进网络。YTrain每一行是一个样本的horizon个目标值,维度是 numSamples × horizon,trainNetwork要求回归目标按这个维度组织。
3.2 归一化和反归一化:必须只用训练集的统计量
多变量数据的量纲差异很大,温度可能是20到30,风速可能是0到8,如果不归一化,CNN卷积核的权重会被大数值变量主导。常见做法是z-score归一化,也就是减均值除标准差。但这里有一个容易忽略的细节:均值和标准差必须只从训练集计算,测试集和验证集都用同一套统计量去变换。
% 假设 dataTrain 是训练集的原始数据,按时间顺序排列 mu = mean(dataTrain, 1); sg = std(dataTrain, 1); dataTrain = (dataTrain - mu) ./ sg; % 验证集和测试集不能用自己算的均值和方差 dataVal = (dataVal - mu) ./ sg; dataTest = (dataTest - mu) ./ sg;这么做是为了防止信息泄漏。如果测试集自己算均值和方差,相当于测试集的部分统计信息提前参与了归一化,验证指标会虚高,部署到新数据上立刻现原形。反归一化时同样只用目标变量那一列的mu和sg,因为你的预测目标只有第一列变量,其他列只是辅助特征,不需要还原。
3.3 训练/验证/测试切分:随机打乱是时间序列的大忌
图像分类里随机打乱数据没问题,但时间序列一旦随机打乱,时间依赖关系就被破坏了。前面构造的滑窗样本之间高度重叠,第k个样本和第k+1个样本只有一行之差,如果随机切分,模型等于见过验证集的一部分内容,指标会严重失真。
正确做法是按时间顺序切分。比如总共有3000条数据,滑窗后得到约2970个样本,我一般取前70%做训练,中间15%做验证,最后15%做测试。测试集必须是时间序列最末尾的一段,这样才接近真实部署时“用过去预测未来”的场景。
切分用数组索引直接做:
numTrain = round(numSamples * 0.7); numVal = round(numSamples * 0.15); XTrain = XAll(1:numTrain); YTrain = YAll(1:numTrain, :); XVal = XAll(numTrain+1:numTrain+numVal); YVal = YAll(numTrain+1:numTrain+numVal, :); XTest = XAll(numTrain+numVal+1:end); YTest = YAll(numTrain+numVal+1:end, :);这里的XAll、YAll是先用3.1节的makeSamples函数生成的完整样本集。切分完成后,验证集参与训练过程的损失监控,但绝不参与参数更新;测试集只在训练全部结束后用一次,用来评判模型真实水平。
4. 在Matlab中搭建CNN-LSTM-Attention网络并完成训练
4.1 网络结构设计:每一层的输入输出尺寸怎么对接
我按输入3个变量、回看24步、预测未来6步为例,把网络结构固定下来。整个网络的层与尺寸对应关系如下表:
| 层名称 | 类型 | 关键参数 | 输出尺寸 |
|---|---|---|---|
| in | sequenceInputLayer | 输入特征数3 | 3 × 24 × batch |
| conv1 | convolution1dLayer | 卷积核5,通道数16 | 16 × 24 × batch |
| relu1 | reluLayer | 无 | 16 × 24 × batch |
| lstm | lstmLayer | 隐藏单元32 | 32 × 24 × batch |
| attn | attentionLayer | 自定义层,权重维度32×1 | 32 × 1 × batch |
| fc | fullyConnectedLayer | 输出神经元6 | 6 × 1 × batch |
| out | regressionLayer | 无 | 6 × 1 × batch |
这里CNN没有接池化层,因为池化会压缩时间步数量,而LSTM需要完整的24个时间步来建模依赖。卷积核大小5意味着每次卷积看5个连续时刻内的多变量特征,输出16个通道相当于用16个不同的特征模板去提取局部模式。LSTM的32个隐藏单元会记住这24步的时序规律,Attention再把24个隐状态加权成一个向量,最后全连接层把这个向量映射成未来6步的预测值。
4.2 自定义Attention层:Matlab没有现成层时的完整实现
Deep Learning Toolbox里没有“对LSTM全部时间步做加权求和并输出一个向量”的内置层,所以需要自己写一个带可学习参数的层。下面这个classdef实现了加性注意力的最小版本,权重向量W对每个时间步打分,softmax把分数变成权重,然后加权求和。代码可以直接保存成attentionLayer.m使用。
classdef attentionLayer < nnet.layer.Layer properties (Learnable) W b end methods function layer = attentionLayer(numUnits, name) layer.Name = name; layer.W = dlarray(randn(numUnits, 1) * 0.01); layer.b = dlarray(0); end function Z = predict(layer, X) % X: numUnits × numSteps × batch % 打分:每个时间步隐状态对W做内积 scores = sum(layer.W .* X, 1) + layer.b; % 1 × numSteps × batch alpha = softmax(scores, 2); % 沿时间步做softmax Z = sum(alpha .* X, 2); % numUnits × 1 × batch end function [dLdX, dLdW, dLdb] = backward(layer, X, Z, dLdZ, memory) alpha = softmax(sum(layer.W .* X, 1) + layer.b, 2); % 计算每个样本每个时间步的梯度中间量 q = sum(X .* dLdZ, 1); % 1 × numSteps × batch qMean = sum(alpha .* q, 2); % 1 × 1 × batch p = alpha .* (q - qMean); % 1 × numSteps × batch % 对可学习参数的梯度 dLdW = sum(sum(X .* p, 2), 3); % numUnits × 1 dLdb = sum(sum(p, 2), 3); % 1 × 1 % 对输入数据的梯度,包含直接路径和打分路径 dLdX = dLdZ .* alpha + layer.W .* p; end end end两个关键点。第一,backward里的p就是损失对每个时间步分数的梯度,q是损失对每个时间步隐状态的梯度,p的计算符合softmax的反向传播形式,不是简单的q乘alpha。第二,dLdX里有两项,前一项来自“加权求和”的直接路径,后一项来自“打分函数依赖输入X”的间接路径,缺了后一项Attention层会学不到东西。这段代码在Matlab的trainNetwork里能被正确调用,前提是把它保存为独立的classdef文件,文件名必须是attentionLayer.m。
4.3 用layerGraph组装网络并配置训练参数
自定义层不能直接像内置层那样一行写进层数组,需要用addLayers加进去。训练参数里,初始化学习率、梯度裁剪、批次大小是最影响成败的三个参数。
numFeatures = 3; windowSize = 24; horizon = 6; numHidden = 32; layers = [ sequenceInputLayer(numFeatures, 'Name', 'in') convolution1dLayer(5, 16, 'Name', 'conv1') reluLayer('Name', 'relu1') lstmLayer(numHidden, 'Name', 'lstm') fullyConnectedLayer(horizon, 'Name', 'fc') regressionLayer('Name', 'out') ]; lgraph = layerGraph(layers); attn = attentionLayer(numHidden, 'attn'); lgraph = addLayers(lgraph, attn); % 把注意力层插在LSTM和全连接层之间 lgraph = connectLayers(lgraph, 'lstm', 'attn'); lgraph = connectLayers(lgraph, 'attn', 'fc'); options = trainingOptions('adam', ... 'MaxEpochs', 150, ... 'MiniBatchSize', 64, ... 'InitialLearnRate', 0.005, ... 'GradientThreshold', 1, ... 'Shuffle', 'never', ... 'Plots', 'training-progress', ... 'Verbose', false);训练选项里,Shuffle设置为never是因为时间序列样本必须保持时间顺序,打乱会破坏滑窗样本之间的重叠结构。GradientThreshold设为1是为了防止LSTM在长序列上梯度爆炸,这个值在时序任务里比调大学习率更管用。InitialLearnRate取0.005对大多数多变量负荷类数据是个安全起点,如果训练震荡就降到0.001,如果收敛太慢再适当提高。
4.4 完整训练与预测:从原始数据到多步结果
把前面几节串起来,完整的训练流程如下。先造样本,再归一化,再切分,然后训练,最后对测试集预测并还原成真实尺度。
% 模拟多变量数据:3个变量,3000个时刻 rng(0); t = (1:3000)'; data = [sin(2*pi*0.01*t) + 0.05*randn(3000,1), ... cos(2*pi*0.02*t) + 0.05*randn(3000,1), ... cumsum(randn(3000,1)*0.005) + 5]; data = normalize(data); % 完整演示建议用3.2节的方式手动归一化 [XAll, YAll] = makeSamples(data, 3, windowSize, horizon); % 按时间顺序切分训练、验证、测试集 numSamples = size(YAll, 1); XTest = XAll(round(numSamples*0.85)+1:end); YTest = YAll(round(numSamples*0.85)+1:end, :); XTrain = XAll(1:round(numSamples*0.7)); YTrain = YAll(1:round(numSamples*0.7), :); net = trainNetwork(XTrain, YTrain, lgraph, options); YPred = predict(net, XTest, 'MiniBatchSize', 1); YPred = squeeze(YPred)'; % 转为 numTestSamples × horizonpredict返回的YPred维度是 horizon × 1 × numTestSamples,因为全连接层输出的每个样本是6×1的向量。squeeze之后变成1×6×numTestSamples,转置成numTestSamples×6后,才能和YTest按行对齐比较。这里MiniBatchSize设为1不是必须的,只是为了让每个样本的预测结果顺序和输入顺序一一对应,避免batch内部打乱带来对齐困难。
5. CNN-LSTM-Attention多步预测避坑指南:四条血泪经验
5.1 训练损失不降反升:先查输入cell数组的维度排列
现象是损失在前几个epoch正常下降,然后突然变成NaN,或者从一开始就在高位震荡。原因多半是输入维度反了。sequenceInputLayer要求每个样本的矩阵是“特征数 × 时间步数”,也就是3×24,很多人习惯写成24×3,LSTM会把这个矩阵解释成24个特征、3个时间步,网络结构看似没报错,实际学的东西完全乱了。
解决方法是打印一个样本的size确认:size(XTrain{1})必须是[3 24]才对。我在3.1节makeSamples里已经把转置做好了,如果你是自己手工构造数据,这一行是最容易出错的地方。另外注意cell数组里每个样本的列数必须一致,trainNetwork不要求同一长度,但滑窗法天然保证一致,所以不用额外处理。
5.2 验证集指标好、测试却崩盘:归一化统计量用错了集合
现象是验证损失很漂亮,但一到测试集上误差翻倍。原因是验证集和测试集各自用了自己的均值和标准差做归一化,或者用了整体数据的统计量。比如先对全部数据做normalize再切分,这时候训练集已经“偷看”了测试集的均值和方差,验证曲线自然好看,部署到新数据上立刻失效。
正确做法是先把原始数据按时间切分,再只用训练集计算mu和sg,验证集和测试集都用训练集的统计量变换。这一点我在3.2节代码里已经体现,但很多人习惯沿用图像处理里的做法,先归一化再切分,这是时序任务里最隐蔽的信息泄漏。
5.3 多步预测后段全是平线:horizon太长或训练样本太少
现象是前两步预测还有波动,第三步往后输出几乎全是一个常数。原因有两个方向。一是horizon取太大,比如区间数据本身只有1000个样本,却要预测未来24步,模型没有足够信息支撑那么远的输出;二是训练样本里远步目标的信号被均值回归压过了,模型发现输出历史均值能拿到最小损失。
解决方法是先缩短horizon验证,比如从6改成3,如果后段恢复波动,说明步长超出数据支撑范围。如果3步也平,就增大windowSize,让模型看到更长的历史周期,同时检查目标变量是否在接近尾声处本身就没有明显波动。也可以用分段预测,把24步拆成4段6步,每段单独训练一个模型,但代价是训练成本成倍增加,属于最后的兜底方案。
5.4 Attention权重几乎均匀分布:打分函数没学到有效区分
现象是打印attention层的alpha权重,每个时间步都差不多是1/24。原因是W初始化太小,打分函数的梯度在softmax里被压平了,或者学习率太低,训练结束时W还没有离开初始值附近。
解决方法是把W初始化从0.01调大到0.1,同时确认GradientThreshold没有把梯度截断得过狠。打印权重可以用一个简单脚本:把测试集某一批数据过一遍predict,然后在自定义层的predict里临时输出alpha,或者直接复算softmax(sum(net.Layers(3).W .* X, 1) + net.Layers(3).b, 2)。如果训练完成后权重依然均匀,优先怀疑是LSTM输出本身已经把所有时间步的信息混成相似向量,这时候增大numHidden比调Attention更有效。
6. 多步预测的三种效果验证手段:不留情面地挨个检查
6.1 分步误差曲线:比一个总指标更诚实
多步预测最常见的错觉是整体RMSE还算能看,但误差集中在最后几步。我建议把horizon里每一步的误差单独算出来,看它随步长的变化趋势,判断模型的远期预测是否只是均值回归。具体做法是先用squeeze还原YPred,然后按列计算每步的MAE和RMSE,画一条“步长-误差”曲线。正常的模型误差会随步长缓慢上升,如果第3步之后误差突然跳变,说明模型对远期步长基本放弃了。
6.2 持久性基线对比:模型好不好要先过这一关
持久性预测是最简单的基线:直接用当前时刻的值当作未来所有步的预测。很多多变量预测模型跑完指标觉得不错,和这个基线一比才发现根本没有优势。把持久性预测的误差曲线和CNN-LSTM-Attention的曲线画在同一张图上,如果模型只在前半段优于基线,后半段被反超,那说明模型学到的长期依赖还不如“原地不动”。这个对比成本极低,但能拦下大量自欺欺人的实验结论。
6.3 注意力权重可视化:定位模型到底在看哪里
最后我会打印一段典型样本的注意力权重,看它是否集中在有物理意义的历史位置上。比如预测未来6小时负荷,注意力权重应该偏向一天前同一时刻附近,而不是随机分布。可视化时把权重按时间步画成柱状图,和原始序列叠在一起观察。如果权重集中在某些明显异常值附近,说明模型学到的其实是对噪声的响应。这个检查不能直接提升精度,但能帮你判断该加CNN卷积核还是该加LSTM隐藏单元,比盲目调参靠谱得多。
我自己的习惯是,每次训练完先跑持久性基线,再画分步误差曲线,最后才看总指标。这套验证流程救过我不少次,希望帮到你。
本文还有配套的精品资源,点击获取