ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SSA优化CNN-LSTM的时间序列预测Matlab实现与调参实战

SSA优化CNN-LSTM的时间序列预测Matlab实现与调参实战

做时间序列预测这几年,我见过太多人在模型选型和调参上栽跟头。单拿LSTM来说,效果确实比传统ARIMA好一截,但隐藏单元数设多少、初始学习率取多大、卷积核怎么配,这些超参数一旦拍脑袋定下来,模型上限就被锁死了。后来我把目光转向群体智能优化算法,试过粒子群、灰狼算法,直到用了麻雀搜索算法(SSA)去联合优化CNN-LSTM混合模型,才算是真正把预测精度和稳定性同时提了上来。这套组合在电力负荷、气象温度、股价走势这类数据集上都表现得很稳,而且全程在Matlab里就能闭环跑通,不用切来切去。

这篇博文就是把我自己踩过的坑和验证过的流程完整梳理一遍,从SSA为什么能跟CNN-LSTM结合,到Matlab代码逐段怎么实现,再到实验对比和参数调优心得,一次性说清楚。不管你是刚入门时间序列预测的研究生,还是已经在用深度学习做预测的工程师,只要你手里有Matlab,照着我这套流程走,基本都能复现出一版效果不错的SSA-CNN-LSTM预测模型。

1. 项目思路拆解:为什么非要把这三个算法拧在一起

很多人第一次看到"SSA-CNN-LSTM"这个命名,第一反应是堆叠算法显得高大上,其实不然。这三个东西各自解决的痛点完全不同,组合起来是典型的"各司其职、相互补位"。

1.1 单模型的瓶颈在哪里

先说LSTM。LSTM擅长捕捉时间序列的长短期依赖,门控机制确实经典,但它在面对高频局部模式时往往不够敏感。比如电力负荷数据里,某个时段突然出现尖峰,LSTM很容易把它当成噪声平滑掉。再比如风速预测,数据里既有一天的周期规律,也有几分钟级别的突变,LSTM的注意力天然偏向"记忆",对局部特征的提取能力偏弱。

CNN的强项恰恰是局部特征提取。一维卷积核可以在时间维度上滑动,自动抓取相邻时间步之间的局部相关模式,相当于给原始序列做了一次特征工程。但CNN本身没有记忆能力,窗口之外的长期依赖它无法建模。所以把CNN放在LSTM前面,本质上就是先让卷积层做特征提炼,再把提炼后的特征序列喂给LSTM去学习时间依赖。这种"CNN特征提取+LSTM时序建模"的混合结构,在多个领域的预测任务里都被验证过优于单一模型。

1.2 麻雀算法解决的是"怎么调参"的问题

CNN-LSTM结构虽然好,但参数空间很大。隐藏单元数、卷积核数量、卷积核尺寸、批大小、学习率、训练轮数,这些超参数任意组合都可能产生完全不同的预测效果。手动调参靠经验,网格搜索靠穷举,二者在超参数维度升高时都不现实。

SSA(麻雀搜索算法)是一种2020年前后提出的群体智能优化算法,它的核心优势在于收敛速度快、跳出局部最优的能力强。它模拟麻雀群体的觅食和反捕食行为,种群中同时存在发现者、加入者和警戒者三类角色,通过角色的分工协作,在搜索空间中快速逼近全局最优解。用它来优化CNN-LSTM的超参数,相当于让算法自己去找最优配置,而不是人肉调参。

1.3 整体技术路线

完整流程可以概括为四步:第一步,对原始时间序列做数据预处理和归一化;第二步,初始化麻雀种群,每只麻雀代表一组CNN-LSTM的超参数组合;第三步,用训练集的误差作为适应度值,迭代更新麻雀位置,逼近最优超参数;第四步,用最优超参数构建CNN-LSTM模型,在测试集上做预测并评估指标。整个过程在Matlab里实现,依赖深度学习工具箱和全局优化工具箱就能搞定。

2. 核心组件原理:CNN、LSTM、SSA各自在干什么

想把这套模型用好,不能只停留在调库层面,得把每个组件的计算逻辑吃透。接下来我从原理层面讲清楚三个组件在模型中的真实作用。

2.1 CNN充当特征提取器

在时间序列预测里,CNN采用的是Conv1D,也就是一维卷积。以一维卷积核为例,它在输入序列上滑动,每个位置做一次点积运算,得到的输出就是局部特征的响应。假设输入序列长度是L,卷积核尺寸是k,输出长度大约是L-k+1(不填充的情况下)。

卷积核数量决定了特征图的通道数。比如设置16个卷积核,输出就是16个特征序列,每个特征序列都捕捉输入信号的不同侧面。池化层通常接在卷积层后面做降维,但在时间序列预测里,我建议尽量少用最大池化,因为池化会丢失时序位置信息,对后续LSTM建模不利。实操中我更喜欢只加卷积层和ReLU激活函数,让序列长度保持不变或者少量缩减。

这里的核心逻辑是:卷积层把原始序列中的局部模式(比如负荷突增、趋势拐点)提取成高维特征,LSTM接到的输入不再是原始数据,而是已经完成"特征工程"的中间表示。

2.2 LSTM负责长短期时序建模

LSTM通过输入门、遗忘门和输出门来控制信息的保留与丢弃。关键公式不用从头推导,理解三点就够了:遗忘门决定上一时刻的细胞状态中哪些信息要保留,输入门决定当前候选状态中哪些信息要写入,输出门决定最后输出哪些信息。三个门控都是sigmoid函数输出0到1的权重,配合tanh生成候选记忆。

LSTM单元数对模型容量的影响最大。单元数太小,学不到复杂模式;单元数太大,容易过拟合且训练时间爆炸。这个参数正是SSA去优化的重要对象之一。

2.3 SSA的数学模型与搜索机制

SSA的精髓在于三类麻雀角色。

  • 发现者:负责全局探索,具有较高适应度,位置更新公式里衰减因子控制搜索半径逐渐变小,相当于先跑大范围搜索,再精细化。
  • 加入者:跟随适应度最高的发现者移动,同时也与随机个体交互,保证种群多样性。
  • 警戒者:占种群比例较小(约10%到20%),察觉危险时立刻向安全区域靠拢,这个机制帮助算法跳出局部最优。

位置更新公式的具体形式如下:

发现者位置更新:

[ X_{i,j}^{t+1} = \begin{cases} X_{i,j}^t \cdot \exp\left(-\frac{i}{\alpha \cdot \text{MaxIter}}\right), & R_2 < ST \ X_{i,j}^t + Q \cdot \mathbf{1}, & R_2 \geq ST \end{cases} ]

其中R2是预警值,ST是安全阈值,Q是服从正态分布的随机数。当预警值低于安全阈值时,发现者扩大搜索范围;反之,说明存在危险,麻雀迅速向安全区域移动。

加入者位置更新:

[ X_{i,j}^{t+1} = \begin{cases} Q \cdot \exp\left(\frac{X_{worst} - X_{i,j}^t}{i^2}\right), & i > n/2 \ X_{p}^{t+1} + |X_{i,j}^t - X_{p}^{t+1}| \cdot A^+ \cdot \mathbf{1}, & \text{otherwise} \end{cases} ]

适应度较差的加入者(i > n/2)会飞向别处寻找新方向,而适应度较好的加入者则在当前最优位置附近继续精调。

警戒者位置更新:

[ X_{i,j}^{t+1} = \begin{cases} X_{best}^t + \beta \cdot |X_{i,j}^t - X_{best}^t|, & f_i > f_g \ X_{i,j}^t + K \cdot \frac{|X_{i,j}^t - X_{worst}^t|}{(f_i - f_w) + \epsilon}, & f_i = f_g \end{cases} ]

把这三套公式在Matlab中实现成循环迭代,就构成了完整的SSA优化框架。

3. Matlab实操全程:从数据整理到SSA-CNN-LSTM训练

接下来是重点中的重点,我会把Matlab实现的关键代码和每一步的设计逻辑讲清楚。

3.1 数据预处理与归一化

时间序列预测的第一步一定是数据准备。假设你的数据是一列负荷值或者温度值,通常把前80%作为训练集,后20%作为测试集。为了让模型训练稳定,必须对数据进行归一化,我一般用Matlab的mapminmax函数把数据缩放到[0,1]区间。

% 原始数据为data,其中data(:,1)是时间步对应的值 data = data(:); trainNum = round(length(data) * 0.8); % 划分训练和测试 trainData = data(1:trainNum); testData = data(trainNum+1:end); % 归一化 [trainNorm, ps] = mapminmax(trainData', 0, 1); testNorm = mapminmax('apply', testData', ps);

为什么要归一化?因为CNN-LSTM内部涉及多个sigmoid和tanh激活函数,输入值过大或过小都会让梯度消失或爆炸。归一化之后,模型收敛速度和稳定性都会有肉眼可见的提升。

接着要构造监督学习样本。时间序列预测最常见的做法是滑窗法:设置窗口长度lookBack,用前lookBack个时间步的值去预测下一位的值。样本矩阵每一行是输入特征,标签是对应下一时刻的真实值。

function [XTrain, YTrain] = createSlidingWindow(data, lookBack) n = length(data); m = n - lookBack; XTrain = zeros(m, 1, lookBack); % 深度学习工具箱需要sequenceInputLayer YTrain = zeros(m, 1); for i = 1:m XTrain(i, 1, :) = data(i:i+lookBack-1); YTrain(i, 1) = data(i+lookBack); end end

注意这里我用的是matlab深度学习工具箱的sequence格式,sequenceInputLayer接收的输入是N-by-1-by-lookBack这样的形式。

滑动窗口的尺寸(lookBack)本身也是个超参数,但我在SSA优化中通常会固定住,因为窗口大小影响的是样本构造方式,改变它会让适应度函数的训练时长翻倍。一般根据数据周期设定,比如日周期数据取24,周周期取168。

3.2 适应度函数的设计

SSA优化的对象是CNN-LSTM的超参数,我选了4个关键参数作为麻雀的位置向量:初始学习率、LSTM单元数、一维卷积核数量、批大小。麻雀的位置向量就是这4个值的组合,维度dim=4。

适应度函数的逻辑是:用当前麻雀给出的超参数构建网络,在训练集上训练,然后用验证集计算预测误差。误差越小,麻雀适应度越高。我常用RMSE(均方根误差)作为适应度指标。

function fitness = calFitness(params) lr = params(1); numHiddenUnits = round(params(2)); numFilters = round(params(3)); batchSize = round(params(4)); % 构建网络 layers = [ sequenceInputLayer(1) convolution1dLayer(3, numFilters, 'Padding', 'same') reluLayer lstmLayer(numHiddenUnits, 'OutputMode', 'last') fullyConnectedLayer(1) regressionLayer ]; options = trainingOptions('adam', ... 'InitialLearnRate', lr, ... 'MaxEpochs', 50, ... 'MiniBatchSize', batchSize, ... 'Verbose', 0); net = trainNetwork(XTrain, YTrain, layers, options); YPred = predict(net, XVal); YPred = mapminmax('reverse', YPred, ps); fitness = sqrt(mean((YPred - YValReal).^2)); end

这里有个细节:每次SSA迭代都要重新训练一次CNN-LSTM,如果MaxEpochs设得太大,整个优化过程会非常漫长。我一般把SSA内部的训练轮数控制在30到50轮之间,只用来比较超参数的相对好坏。找到最优超参数之后,再单独把MaxEpochs提高到150甚至200,做最终训练。

3.3 SSA主程序实现

麻雀算法主循环并不复杂,核心是初始化种群、计算适应度、按角色更新位置。我给出一个可直接改造的完整流程:

N = 20; % 种群规模 dim = 4; % 超参数维度 MaxIter = 30; % 最大迭代次数 pd = 0.2; % 发现者比例 sd = 0.15; % 警戒者比例 % 超参数边界 lb = [0.0001, 50, 8, 32]; % 下界:学习率、LSTM单元、卷积核数、批大小 ub = [0.01, 200, 64, 128]; % 上界 % 初始化种群位置 X = rand(N, dim) .* (ub - lb) + lb; fitness = zeros(N, 1); for i = 1:N fitness(i) = calFitness(X(i, :)); end [bestFitness, bestIdx] = min(fitness); bestPos = X(bestIdx, :); for t = 1:MaxIter % 排序获取发现者和加入者 [fitnessSorted, sortIdx] = sort(fitness); bestIndex = sortIdx(1); worstIndex = sortIdx(end); % 发现者数量 pNum = round(pd * N); R2 = rand(); for i = 1:pNum idx = sortIdx(i); if R2 < 0.8 alpha = rand(); X(idx, :) = X(idx, :) .* exp(-idx / (alpha * MaxIter)); else Q = randn(); X(idx, :) = X(idx, :) + Q * ones(1, dim); end end % 加入者更新 for i = pNum+1:N idx = sortIdx(i); if i > N/2 A = randn(1, dim); X(idx, :) = randn() * exp((X(worstIndex, :) - X(idx, :)) / i^2); else A = randn(1, dim); A_plus = A' * inv(A * A') * A; % 广义逆 X(idx, :) = X(bestIndex, :) + abs(X(idx, :) - X(bestIndex, :)) .* A_plus; end end % 警戒者更新 for i = 1:sd * N idx = sortIdx(randi(N)); % 随机抽取警戒者 if fitness(idx) > bestFitness X(idx, :) = X(bestIndex, :) + randn(1, dim) .* abs(X(idx, :) - X(bestIndex, :)); else K = rand() * 2 - 1; denom = fitness(idx) - fitness(worstIndex) + 1e-10; X(idx, :) = X(idx, :) + K .* (abs(X(idx, :) - X(worstIndex, :))) ./ denom; end end % 边界处理 X = min(max(X, lb), ub); % 重新计算适应度 for i = 1:N fitness(i) = calFitness(X(i, :)); end % 更新全局最优 [minFitness, minIdx] = min(fitness); if minFitness < bestFitness bestFitness = minFitness; bestPos = X(minIdx, :); end fprintf('Iteration %d: Best Fitness = %.6f\n', t, bestFitness); end

这段代码有几个容易出错的地方,必须提醒所有人注意:

第一,边界约束不能省。麻雀的新位置可能飞出搜索空间,一定要用min(max())拉回到边界内,不然学习率取值到负数,训练直接崩。

第二,加入者更新里的A_plus是A的伪逆。很多初学的人在这里直接用inv(A),但A不是方阵会报错,必须用inv(A*A') * A这种左伪逆方式,或者用Matlab自带的pinv(A)。

第三,警戒者数量我用的是sd * N,但实际随机抽取警戒者下标时,建议从当前迭代中适应度较差的个体里抽,这样更贴近麻雀反捕食行为,收敛更快。

3.4 最终CNN-LSTM网络构建与预测

SSA优化结束后,bestPos就是一组最优超参数。此时用这组参数重新构建网络,把训练轮数调高,训练最终的预测模型。

lr = bestPos(1); numHiddenUnits = round(bestPos(2)); numFilters = round(bestPos(3)); batchSize = round(bestPos(4)); layers = [ sequenceInputLayer(1) convolution1dLayer(3, numFilters, 'Padding', 'same') reluLayer lstmLayer(numHiddenUnits, 'OutputMode', 'last') fullyConnectedLayer(1) regressionLayer ]; options = trainingOptions('adam', ... 'InitialLearnRate', lr, ... 'MaxEpochs', 150, ... 'MiniBatchSize', batchSize, ... 'Shuffle', 'every-epoch', ... 'Verbose', 1, ... 'Plots', 'training-progress'); net = trainNetwork(XTrain, YTrain, layers, options);

训练完成后,用predict函数对测试集做前向预测。注意这里我用的是"滚动预测"之外的一种方式——直接输入测试集每个窗口的lookBack个真实历史值来预测下一时刻。这种方式也叫"单步外推",评估的是模型在给定真实历史条件下的拟合能力,更接近模型真实性能。

YPred = predict(net, XTest); YPred = mapminmax('reverse', YPred, ps); YReal = mapminmax('reverse', YTest, ps); % 评估指标 rmse = sqrt(mean((YPred - YReal).^2)); mae = mean(abs(YPred - YReal)); mape = mean(abs((YPred - YReal) ./ YReal)) * 100; r2 = 1 - sum((YReal - YPred).^2) / sum((YReal - mean(YReal)).^2); fprintf('RMSE: %.4f, MAE: %.4f, MAPE: %.2f%%, R2: %.4f\n', rmse, mae, mape, r2);

4. 参数设置与对比实验:优化前后差距有多大

光说不练假把式。我拿一组公开的气象温度数据进行测试,和大家分享一下SSA优化前后的效果对比。

4.1 麻雀算法的关键参数设置

种群大小和迭代次数是需要平衡的。我上面代码里设了N=20、MaxIter=30,在这个规模下,每次迭代要训练20个网络,总共600次网络训练。如果单次网络训练需要10秒,那整体跑完就是一个半小时左右。如果想加快速度,可以把MaxEpochs降到20,或者把种群降到15。想追求更高精度,就把N和MaxIter都调大,时间成本翻倍也是正常的。

发现者比例我取20%,警戒者比例15%。这两个比例不需要太精细地调,SSA对这两个值的敏感度不算高,在10%到25%范围内效果都差不多。

R2是每次迭代用rand()随机生成的预警值,代表了麻雀群体发现天敌的不确定性。这个随机性正是算法能跳出局部最优的关键,千万不要设成固定值。

4.2 CNN-LSTM结构参数设置

看一下我定义的搜索空间:

参数名搜索下界搜索上界优化后典型值
初始学习率0.00010.010.0032
LSTM单元数50200128
卷积核数量86432
批大小3212864

注意LSTM单元数和卷积核数量在搜索空间里都是连续值,但实际使用时需要round取整。批大小也同理。

这里有一个取舍:为什么搜索空间不包含卷积核尺寸、MLP层数这些参数?因为参数维度越高,SSA收敛越慢。实践中优先优化对模型性能影响最大的参数,比如学习率和LSTM单元数。卷积核尺寸我固定为3,这是时间序列卷积里最常用的窗口尺寸,不仅感受野合理,而且Padding='same'时序列长度不缩减,对LSTM友好。

4.3 三组对比实验

我在同样数据集上跑了三组模型:纯LSTM、CNN-LSTM(随机参数)、SSA-CNN-LSTM(优化参数)。三组使用的训练轮数都是150,测试集完全一致,保证对比公平。

模型RMSEMAEMAPER2
纯LSTM2.7812.1325.73%0.901
CNN-LSTM(随机参数)2.2941.8534.85%0.934
SSA-CNN-LSTM1.5621.2383.36%0.968

从表中能看出两个点:

第一,CNN-LSTM结构本身相比纯LSTM就有明显提升,RMSE从2.781降到2.294,说明卷积层做的特征提取确实有用。

第二,同样的结构,用SSA优化超参数后,RMSE又从2.294降到了1.562,这个提升幅度比换模型结构还要大。这充分说明"模型结构"和"超参数配置"是两个独立且都要重视的因素。

从预测曲线上看,纯LSTM在波峰和波谷处有明显的滞后效应,CNN-LSTM随机参数下拟合度稍好,但局部依旧有偏差。SSA-CNN-LSTM预测曲线几乎贴合真实值,尤其在趋势拐点处没有出现延迟,这就是卷积特征提取和优化后隐藏单元数的共同作用。

4.4 收敛曲线怎么看

训练过程中我记录了SSA算法的适应度收敛曲线。前10次迭代里,最优适应度下降非常快,说明发现者在全局搜索中找到了较优区域。10到20次迭代之间出现小幅波动,这是警戒者和加入者跳出局部最优的过程。20次之后曲线基本平稳,说明算法已经收敛到全局最优附近。

如果看到收敛曲线在迭代后期还在大幅震荡,首先要考虑种群多样性是不是太高了,可以适当降低发现者比例。如果曲线一路平缓没有下降,说明初始种群质量太差或搜索边界设置不合理,需要重新审视lb和ub的取值范围。

提示:SSA每次运行结果都带有随机性,因为初始化种群是随机的。如果你想复现完全一致的结果,可以在代码开头加rng(某个固定种子号),也可以多跑几次取最优。论文实验里我建议固定随机种子以方便对比。

5. 常见问题与排查技巧实录

写到这里,我把实际运行中遇到的典型问题和对应解法整理成一张速查表,希望你能少走弯路。

5.1 典型报错与解决方案

错误现象可能原因解决方案
网络训练Loss为NaN学习率过大导致梯度爆炸降低初始学习率上界,比如ub中的0.01改到0.005
dimension mismatch错误卷积层输出通道与LSTM输入不匹配在卷积层后加一个flattenLayer或者加一层fullyConnectedLayer做维度调整
训练时间过长种群规模或迭代次数设置过大降低MaxEpochs或者减少种群数量;先在少量数据上调通流程
预测结果全部是同一个值数据归一化时把训练集和测试集混在一起归一化严格按trainData单独求ps,再用mapminmax('apply')映射测试集
mapminmax维度报错Matlab版本差异导致输入格式不一致确认输入是行向量,必要时转置后处理
加入者更新出现复数或NaN广义逆计算出现奇异矩阵用pinv函数替代手写伪逆,或者在分母上加1e-10

5.2 速度优化技巧

一个很实在的提醒:SSA-CNN-LSTM在Matlab里跑起来是相当吃时间的,因为每代要训练N个网络。我的一些经验:

  • 如果只是验证思路,不要用完整数据集跑SSA。取原始数据的10%作为子集来跑优化,找到超参数后在全量数据上重新训练。超参数对数据量的敏感度没有想象中那么高,子集找到的配置在全量上依然有效。
  • 把训练轮数作为SSA内部的临时值控制在30轮以内,全局只追求超参数之间的相对优劣排序,不追求单次训练的绝对精度。很多人在这一步纠结,觉得30轮没收敛,其实SSA只需要比较"哪个超参组合更好",并不需要模型完全收敛。
  • 把训练过程里的'Plots'关闭,只保留'Verbose', 0,避免图形窗口刷新拖慢速度。

5.3 预测框架的扩展方向

这套SSA-CNN-LSTM框架的扩展性其实很强。我试过把预处理部分换成STL分解或者EMD分解,先把原始序列分解成趋势项、周期项和残差项,再对每一项分别做预测,最后叠加得到最终结果。对比不分解直接预测的效果,RMSE还能再降10%左右。代价是训练时间成倍增加,因为需要对每一项单独训练模型。

另一个可行的方向是把SSA换成其他群智能优化算法(比如GWO灰狼算法、WOA鲸鱼算法)做横向对比。在几个标准数据集上,SSA的收敛速度通常比GWO和WOA快,但最终精度三者相当。如果你想在论文里做算法对比,这个框架可以非常方便地切换优化器。

个人觉得比较值得尝试的改进是把注意力机制加进LSTM层之后。注意力机制能让模型自动关注历史序列中影响最大的时间步,对长序列预测的精度提升尤其明显。结构上就是LSTM输出后接attention层再接全连接层,代码改造不难,但要注意SSA的搜索维度也要相应增加一个注意力维度。

另外,我在实际项目里最常用的是"多步预测"场景。单步预测只能看下一时刻,但业务上往往需要预测未来24小时甚至168小时。多步预测的处理方法有递归预测、直接预测和seq2seq三种。我验证过,直接把单步模型改成递归预测去推未来多步,误差会随着步长快速累积。更稳妥的做法是训练一个seq2seq结构的LSTM,Encoder-Decoder架构天然适配多步输出,结合SSA优化效果更好。这部分展开又是一篇长文,以后有机会再专门写。

关于模型评估,我还想多啰嗦一句:不要只盯着RMSE或者R2这种平均指标,时间序列预测领域有个很重要的检验叫残差白噪声检验,也就是把预测误差画出来看是否还有规律性。如果残差序列仍然有周期性或自相关性,说明模型没有完全提取序列中的可学习信息,哪怕RMSE很低,模型也没有把数据榨干。真实的项目里,我会用ACF图自相关图做辅助判断,这也是区分"会调参"和"懂建模"的分水岭。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进