做时间序列预测的人,十有八九都卡在同一个问题上:手里拿着多变量数据,却不知道怎么把空间特征和时间依赖同时吃进去。CNN擅长抓局部模式,LSTM天生处理时序,两个拼一起听起来很合理,但真动手写代码、调网络结构的时候,坑一个接一个。这篇就来聊聊用Matlab实现CNN-BiLSTM做多变量/时间序列预测的完整流程,从数据处理、滑窗构造、网络搭建到训练预测,全程给可直接抄的代码和参数经验。如果你是刚接触深度学习的Matlab用户,或者已经在用RNN但想进一步提升预测精度,这篇文章应该能帮你少走很多弯路。
1. 为什么多变量预测绕不开CNN-BiLSTM
1.1 多变量时间序列到底难在哪
单变量时间序列预测,比如只看历史温度预测未来温度,本质上是“一条线上的游戏”。多变量就不一样了,它同时涉及多个特征,比如空气污染预测里同时有PM2.5、SO2、NO2、CO、温度、湿度,每个特征都对目标值有影响,而且彼此之间还互相纠缠。你要处理的不是一个序列,而是一组序列的联合演化。
这种问题难在三个层面。第一,特征之间的局部关联很难手工建模。某个污染物浓度骤升,往往意味着PM2.5会在后续几个小时内爬升,这种“变量A在一段时间窗内对变量B的影响”,靠人工构造特征既费劲又不全面。第二,时间依赖是长跨度的。气象类数据尤其明显,今天的湿度变化可能影响明天乃至后天的污染扩散,用普通的前馈神经网络根本兜不住这种长期记忆。第三,数据里全是非线性关系。线性模型搞不定,传统统计方法比如VAR也做得吃力,深度学习方法反而成了最现实的选择。
1.2 CNN和BiLSTM各自扮演什么角色
CNN在这套组合里的定位是“特征提取器”。它通过卷积核在时间维度上滑动,自动学习相邻几个时间步内的局部模式。一维卷积非常适合这种场景:核大小为3的时候,每个卷积核只看最近的3个时刻,提取范围内的局部趋势、突变、周期性片段。多个卷积核相当于多个“观察视角”,有的关注上升趋势,有的关注波动幅度,有的关注值之间的差值。CNN把多变量数据里的空间特征压缩成更紧凑的表达,再往下传。
BiLSTM接在CNN后面,干的是“时序建模”的活儿。LSTM的优点是带门控机制,能记住重要信息、忘掉无关信息,从而捕捉长距离依赖。BiLSTM比普通LSTM更进一步:它同时从正向和反向两个方向读序列。你可以理解成读一句话的时候,既从左往右看语法,又从右往左看语境,双向信息综合起来判断。对于离线预测任务——也就是整个序列已经拿到手,再预测未来的场景——这种双向建模能更充分地利用上下文信息。
1.3 为什么不直接用单LSTM或单CNN
很多人会问,都用上BiLSTM了,何必还要前面的CNN?直接堆LSTM不就行了?
理论上可以,但效果通常不理想。纯LSTM处理高维多变量输入时,每个时间步要同时消化所有特征,信息太密集,模型很难自动区分哪些特征在局部时间窗内更重要。简单说就是“眉毛胡子一把抓”。而CNN的权值共享和局部感受野能先做一步关键筛选,把原始输入转化成更紧凑的特征序列,再喂给BiLSTM,后者就可以集中精力学时间先后关系。
反过来只用CNN也不行。CNN擅长提取局部特征,但感受野始终有限,哪怕堆很多层,对长距离依赖的建模能力依然弱于LSTM。另一个现实问题是,CNN的输出是定长的,对变长时间序列不友好,而LSTM天然支持变长序列。所以CNN-BiLSTM是一种互补结构:先用CNN收拾局部特征,再用BiLSTM收拾全局时序,各干各擅长的事。
| 模型 | 优点 | 缺点 |
|---|---|---|
| 纯LSTM | 时序建模强,结构简单 | 高维多变量输入时特征利用效率低 |
| 纯CNN | 训练快,局部特征提取能力强 | 长期依赖建模弱,感受野受限 |
| BiLSTM | 双向上下文建模,长依赖好 | 参数多,训练慢,容易过拟合 |
| CNN-BiLSTM | 局部特征与双向时序兼顾 | 超参数变多,调参成本上升 |
Matlab这块的实现也有别的选择,比如GRU、Attention、Transformer,但对大多数工程场景来说,CNN-BiLSTM是一个性价比极高的方案:训练成本可控,预测精度已经能打赢大多数普通模型,而且Matlab的Deep Learning Toolbox对这些网络层的支持很完善,不需要自己写底层算子。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手之前:数据清洗、归一化与滑动窗口构造
2.1 数据清洗与归一化
很多人一上来就搭网络,结果预测效果稀烂,最后发现数据没洗干净。时间序列数据最容易出问题的地方就是缺失值。传感器断传、网络异常、人工记录漏写,都会产生NaN,深度学习模型遇到NaN基本就是灾难。常规做法是用fillmissing做线性插值,短段缺失线性插值够用,长段缺失可以考虑前面的均值填充,但别指望插值能创造信息,如果缺失太多,该删就删。
归一化这一步绝对不能省。CNN-BiLSTM用的是梯度下降类优化器,特征取值范围差异太大会导致训练震荡、收敛缓慢。比如温度是-10到40这种个位数级,而污染物浓度可能从0到500,量纲差异直接让损失函数地形变得特别倾斜。推荐把数据统一缩放到0到1之间。Matlab里的mapminmax函数是最顺手的工具,注意要保存好归一化参数,预测完再用它做反变换,把结果还原成真实量纲。
2.2 滑动窗口与标签构造
深度学习时间序列预测的本质是监督学习,所以要把无标签的时间序列变成有标签的样本。标准做法是滑动窗口:用过去stepSize个时间步的观测值作为输入X,未来horizon个时间步的目标值作为标签Y。窗口大小和预测步长直接决定了任务形态。
假如stepSize等于24、horizon等于1,输入是过去24小时的多变量数据,输出是下一小时的预测值,这是单步多变量预测。如果horizon大于1,输出未来多个时刻的值,就是多步预测。窗口大小的选择没有定论,基本原则是:窗口要能覆盖数据的主要变化周期。日周期数据至少24个点,周周期数据至少168个点,实在不确定可以多试几组,对比训练损失和测试误差。
构造样本的时候有个容易忽略的点:如果数据有N行,窗口长度是step,预测步长是horizon,那么样本总数是N - step - horizon + 1。代码里要小心边界,别把最后一段造出越界样本。
2.3 数据集划分原则
训练集、验证集、测试集的划分,在时间序列任务里不能像普通机器学习那样随机打散。随机划分会“偷看未来”,训练集里出现测试时间段的数据,模型相当于开了天眼,测试误差会严重失真。正确做法是严格按时间先后顺序切分:前80%做训练集,中间10%做验证集,最后10%做测试集。
Matlab里最简单的做法是直接用索引切片:
matlab复制numTrain = floor(numSamples * 0.8);
numVal = floor(numSamples * 0.1);
XTrain = X(1:numTrain);
YTrain = Y(1:numTrain, :);
XVal = X(numTrain+1:numTrain+numVal);
YVal = Y(numTrain+1:numTrain+numVal, :);
XTest = X(numTrain+numVal+1:end);
YTest = Y(numTrain+numVal+1:end, :);
这么切之后,训练、验证、测试三段在时间上是连续且不重叠的,模型的评估结果才有说服力。
3. Matlab代码实战:完整跑通CNN-BiLSTM
3.1 环境准备与网络定义
先说环境。Matlab R2021a以上的版本都能流畅跑下面的代码,建议安装Deep Learning Toolbox。如果你用的是更早的版本,convolution1dLayer和maxPooling1dLayer这些层可能不被支持,那建议先升级Matlab,别跟自己过不去。
核心代码如下,我以空气污染数据为例,数据矩阵data的每一行是一个时刻,第一列是目标PM2.5浓度,后面各列是SO2、NO2、CO、温度、湿度等特征:
matlab复制% 读取数据,假设data是单矩阵,每列一个特征,第一列是目标
data = readmatrix('air_quality.csv');
% 缺失值处理
data = fillmissing(data, 'linear');
% 归一化到[0,1]区间,注意mapminmax针对每行操作,所以先转置
[X_norm, ps] = mapminmax(data', 0, 1);
X_norm = X_norm';
% 滑动窗口构造样本的函数
stepSize = 24;
horizon = 6;
function [X, Y] = createSeq(data, step, horizon)
n = size(data, 1);
numSamples = n - step - horizon + 1;
X = cell(numSamples, 1);
Y = zeros(numSamples, horizon);
for i = 1:numSamples
X{i, 1} = data(i:i+step-1, :)';
Y(i, :) = data(i+step:i+step+horizon-1, 1)';
end
end
[X, Y] = createSeq(X_norm, stepSize, horizon);
这里的X用了cell数组,这是Matlab序列神经网络的标准输入格式。每个cell里是一个矩阵:行数是特征数,列数是时间步数。直观理解就是每个样本变成了一张“特征×时间”的二维小图,卷积层就沿着时间轴在这个小图上滑窗提取特征。
网络定义是这套流程最容易出错但也是最核心的部分。我用的结构是两层一维卷积加两个BiLSTM,收敛速度和精度都比较平衡:
matlab复制numFeatures = size(data, 2);
layers = [
sequenceInputLayer(numFeatures)
convolution1dLayer(3, 32, 'Padding', 'same')
batchNormalizationLayer
reluLayer
maxPooling1dLayer(2, 'Stride', 2)
convolution1dLayer(3, 64, 'Padding', 'same')
batchNormalizationLayer
reluLayer
maxPooling1dLayer(2, 'Stride', 2)
bilstmLayer(64, 'OutputMode', 'last')
dropoutLayer(0.2)
fullyConnectedLayer(horizon)
regressionLayer
];
为什么要写两层卷积?第一层32个卷积核负责提取基础局部特征,比如短期突增、趋势斜率;第二层64个卷积核在上一层输出的基础上组合出更高阶的局部模式。加batchNormalizationLayer主要是为了稳定训练,防止梯度消失或者梯度爆炸。池化层把时间步数降下来,降低后续BiLSTM的计算负担,但也别池化得太狠,时间信息丢多了反而影响预测。
3.2 训练配置与参数选择
网络定义好了之后,训练选项是下一个关键决策点。我用的是Adam优化器,这是当前时间序列预测任务最省心的默认选择,自适应学习率,基本不需要手动微调太多:
matlab复制options = trainingOptions('adam', ...
'MaxEpochs', 200, ...
'MiniBatchSize', 64, ...
'InitialLearnRate', 0.001, ...
'LearnRateSchedule', 'piecewise', ...
'LearnRateDropFactor', 0.5, ...
'LearnRateDropPeriod', 30, ...
'GradientThreshold', 1, ...
'Shuffle', 'every-epoch', ...
'Plots', 'training-progress', ...
'Verbose', 1);
net = trainNetwork(X, Y, layers, options);
几个参数说下我的理解。InitialLearnRate设成0.001是深度学习的“默认安全区”,太大容易震荡,太小收敛得让人着急。GradientThreshold设成1是为了防止梯度爆炸,BiLSTM比普通LSTM更容易出现梯度问题,这个值建议别省,尤其数据噪声大的时候。MiniBatchSize设64,如果你的数据量特别小,可以降到32。Shuffle在每个epoch开始前打乱样本顺序,可以避免模型记住序列顺序而不是学规律。
如果数据大、训练时间长,强烈建议设置验证集,并开启'ValidationData'参数。这样Matlab会在训练过程中实时计算验证集误差,配合'OutputFcn'实现提前停止,防止训练集误差一直降但验证集误差开始反弹的过拟合情况。
3.3 预测、反归一化与指标计算
训练完的模型拿来做预测,有个细节必须处理到位:网络输入要求cell数组,输出的是归一化后的预测值。预测之后要做反归一化,把数值还原成PM2.5真实浓度,否则你得到的是0到1的数值,没有任何物理意义。反归一化的时候要注意,mapminmax的ps对象里保存了训练数据的xmin和xmax,直接用同一个ps做反变换:
matlab复制YPredNorm = predict(net, XTest);
% 反归一化:因为训练时对data整体做了归一化,返回时需要还原全部列
YPredAll = mapminmax('reverse', YPredNorm', ps)';
YPredTarget = YPredAll(:, 1);
% 测试集真实值的反归一化同理
YTestAll = mapminmax('reverse', YTest', ps)';
YTestTarget = YTestAll(:, 1);
注意这里反变换的是所有特征列,但目标只取第一列。严格来说更好的做法是只对目标列单独归一化,不过对所有列统一做归一化再反归一化取目标列,只要确保训练和测试用同一套ps,在实际操作中是可行的,代码上也更简洁。
评估指标我一般用三个:RMSE表达绝对误差大小,MAE表达平均绝对偏差,MAPE表达相对误差百分比。MAPE要注意数据里有0的情况,PM2.5偶尔接近0的时候会算出无穷大,所以有时候会用SMAPE代替。代码如下:
matlab复制rmse = sqrt(mean((YTestTarget(:) - YPredTarget(:)).^2));
mae = mean(abs(YTestTarget(:) - YPredTarget(:)));
mape = mean(abs((YTestTarget(:) - YPredTarget(:)) ./ (YTestTarget(:) + eps))) * 100;
fprintf('RMSE: %.4f\n', rmse);
fprintf('MAE: %.4f\n', mae);
fprintf('MAPE: %.4f%%\n', mape);
3.4 结果可视化与训练过程怎么看
画图是判断模型质量最直观的手段。预测值和真实值叠在一张图上,如果预测曲线能跟上真实曲线的趋势,但存在相位延迟,说明模型结构没问题,可能需要调整窗口大小或者加深网络;如果曲线乱七八糟毫无规律,可能是数据没洗好或者模型没收敛。
matlab复制figure;
plot(YTestTarget, 'LineWidth', 1.2); hold on;
plot(YPredTarget, 'LineWidth', 1.2);
legend('真实值', '预测值');
xlabel('测试样本序号');
ylabel('PM2.5浓度');
title('CNN-BiLSTM多变量预测结果');
除了最终预测图,训练过程曲线更值得仔细看。Matlab的'Plots', 'training-progress'会实时画出训练损失和RMSE曲线。正常情况下训练损失应该是平滑下降后趋于平稳。如果损失曲线震荡剧烈,先检查学习率是不是太大;如果训练损失降得很慢,则可能是学习率太小或者网络深度不够。
这里有个经验分享:训练损失下降到一定程度后,如果验证损失开始反弹上行,那就是经典的过拟合信号,此时不是继续训练能解决的,而是要回到网络结构上做调整,比如增加dropout比例,或减小BiLSTM隐藏单元数。
4. 常见问题与避坑实录
4.1 维度报错:序列与卷积层的配合
新手在Matlab里跑CNN-BiLSTM,遇到最多的报错就是维度问题。典型错误是The input to the convolution1dLayer must be a formatted dlarray或者Number of channels mismatch。这通常是因为sequenceInputLayer输出的格式和convolution1dLayer期望的输入格式对不上。
解决方案有三个思路:检查sequenceInputLayer的输入特征数是否等于数据矩阵的列数;检查convolution1dLayer和maxPooling1dLayer的参数是否让时间步维度降成了0或者负数;确认你的Matlab版本支持卷积层和序列层直接连接。如果你的版本比较老,那需要在卷积层之前加一层sequenceFoldingLayer,卷积层之后加sequenceUnfoldingLayer,把序列折叠成普通二维特征图处理完再展开回序列。这个写法在Matlab官方网站有专门例子,遇到版本问题直接搜就能找到。
4.2 数据泄漏:归一化与划分的坑
数据泄漏是时间序列预测里最隐蔽的坑,你可能模型指标漂亮得不行,一到实际应用就拉胯。最常见的泄漏方式就是用全部数据的均值、最大值、最小值去做归一化,再把数据划分成训练集和测试集。这相当于测试集的信息已经在归一化阶段“偷看”过了。正确做法是先划分,再用训练集的统计量去归一化训练集,然后用同一组参数归一化测试集。业界把这个叫“fit on train, transform on test”。
不过我上面给的代码简化了这个过程,直接对全量数据做了归一化再划分,严格来说这是一种轻度泄漏。实操中如果想要严谨,代码应该调整为:先划分原始数据,只对训练部分计算ps,再分别归一化训练、验证、测试三部分。这个改动不难,但很多人意识不到。
4.3 过拟合与欠拟合的表现和处置
CNN-BiLSTM因为参数量大,在中小规模数据集上很容易过拟合。症状是训练集损失持续下降,验证集和测试集损失却在某个点后回升。对策优先级从高到低依次是:增大dropoutLayer的丢弃率,从0.2调到0.3或0.5;减小bilstmLayer的隐藏单元数,从64降到32;减小fullyConnectedLayer的输出规模;增加训练数据的量,或者做数据增强。
欠拟合的表现刚好相反,训练损失和测试损失都降不下去,模型根本没有学到数据规律。此时优先加大HiddenUnits到128或256,增加卷积层数量,或增大卷积核数量。
4.4 预测曲线平移现象
跑过时序预测的朋友应该都见过这种情形:预测曲线形状和真实曲线很像,但整体往后平移了一段,看起来像是把过去的真实值搬到了未来。这在统计上是“滞后效应”的表现,常见于数据本身具有强自相关的情况。模型学到的最简单策略就是:下一时刻的值约等于这一时刻的值,所以预测结果等于某种平滑后的滞后值。
处理手段有几种,最常规的是加长滑窗步长,强迫模型看更多历史;再就是引入差分特征,把目标值的变化量也作为输入特征,迫使模型学习增量规律;还有一种思路是调低输出的平滑性,比如在损失函数里增加预测值之间差值的惩罚项。这个现象很难完全消除,但能控制在一个可接受的范围。
4.5 训练太慢怎么办
Matlab在CPU上训练深度学习模型确实比较磨人,尤其数据量大、网络层数深的时候。优先办法是使用NVIDIA显卡并安装CUDA和cuDNN,Matlab的gpuDevice函数可以确认是否使用GPU训练。如果只能CPU训练,建议做三件事:把MiniBatchSize调小到16或32,减少单次计算量;降低MaxEpochs到50,用验证集提前停止;减小BiLSTM隐藏单元数和卷积核数量。
| 问题 | 现象 | 优先解决方案 |
|---|---|---|
| 维度报错 | 卷积层输入尺寸不匹配 | 检查特征数、时间步数、版本兼容性 |
| 数据泄漏 | 测试效果虚高、实测崩溃 | 先划分后归一化,复用训练集参数 |
| 过拟合 | 训练损失低、验证损失反弹 | 增大dropout,减小隐藏单元数 |
| 预测滞后 | 预测曲线整体平移 | 加长窗口、引入差分特征 |
| 训练太慢 | 每次迭代时间过长 | GPU加速、减小批大小、减少网络参数 |
5. 调参会扩展:如何把模型用到自己的数据上
5.1 几个关键超参数的经验值
调参是个经验活,但也不是完全没规律。第一个关键参数是滑动窗口长度。我的经验是:先看数据是否有明显周期,日周期给24到48,如果是高频分钟级数据,窗口可以适当加大。第二是卷积核大小,默认3就够了,数据集大的时候可以试5。第三是BiLSTM隐藏单元数,从32开始试,每次翻倍,取验证集表现最好的那个。第四是dropout比例,默认0.2,出现过拟合再往0.5加。
学习率的调整策略我建议这样做:先用0.001训练20个epoch观察损失下降趋势。如果损失震荡,降到0.0005;如果损失几乎不动,升到0.005。学习率衰减用piecewise策略即可,每30个epoch降一半是很稳的起点。
5.2 多步预测的三种策略
多步预测不只是把输出维度改成horizon那么简单。常用的策略有三种:第一种是最简单的“多输出”,就是本文示例的做法,一次预测出未来多个时刻的数值,训练快,但预测长度增加时误差会累积。第二种是“递归预测”,先用单步模型预测出下一时刻的值,再把预测值放回输入继续预测后续时刻,实现简单但误差会随步数快速膨胀。第三种是“seq2seq”结构,编解码器配合注意力机制,是长程多步预测的目标方案,但训练复杂度明显上升。
选哪种取决于你的预测长度。预测1到6步,用多输出就够;预测几十步以上,建议认真研究seq2seq思路。CNN-BiLSTM本身已经能跟seq2seq里的编码器部分对接,在后面再接一个解码器LSTM就能实现更长的预测。
5.3 从CNN-BiLSTM还能往哪扩展
CNN-BiLSTM不是终点,但它是很好的基座。在此基础上可以做几个方向的扩展:一是加入Attention机制,让模型在解码的时候能“回头注意”输入序列的关键位置,在处理长序列时提升明显。二是把传统的全连接输出层换成上采样层或者反卷积层,用于时空预测任务。三是结合经验模态分解或者小波变换做数据预处理,把非线性序列按频率成分分解后分别建模,再把预测结果叠加起来。四是在输入端加入位置编码,把时间信息显式编码进特征里。
这些扩展在Matlab里都有对应的工具箱或模块,Deep Learning Toolbox的attentionLayer在较新版本里已经支持,做起来并没有想象中的门槛。
最后再分享一点个人实战中的体会。模型选型、调参这些事看起来高大上,但决定一个时间序列预测项目能否落地成功的,永远是数据质量和评测方法。如果你发现模型怎么调都比不上一个简单的线性回归,先别怀疑神经网络不行,多半是数据没洗干净,或者窗口构造出了问题。我早期做这类模型的时候,最喜欢在测试集上折腾指标,后来才明白:真正可靠的模型不是在测试集上最漂亮的那个,而是在换了新数据之后依然稳定的那个。建议你先用传统模型建立一个基线,再叠加CNN-BiLSTM,量化清楚每一步带来的提升,这样调参才能做到心里有数。
