
简介面向深度学习初学者与MATLAB用户的CNN-LSTM组合模型源码包含完整可运行的m脚本和配套csv原始数据用于解决序列数据中空间特征提取与长期依赖建模问题。压缩包共2个文件以Matlab源码和表格数据为主整体仅13MB轻量易用。脚本覆盖数据读取、预处理、CNN卷积池化特征提取、LSTM门控层构建、训练与预测全流程并将模型串联为统一网络便于读者理解两种结构的协作方式同时掌握常用深度学习函数的使用技巧。csv文件为原始输入数据可直接替换成自己的数据集进行实验该数据集规模适中运行环境要求不高便于快速复现。目前已有10357人浏览学习考虑到资源体积小、上手门槛低尤其适合刚入门的深度学习实践者作为课程设计或毕业设计的参考项目也可作为后续网络结构优化的参考基线。1. 为什么非要把CNN和LSTM拧在一起先说个我自己的体会早期做时间序列预测的时候我也用过纯LSTM也试过纯CNN但总是差点意思。LSTM擅长抓长期依赖窗户纸捅破了就是两个字——记忆。它能把几十步之前的信息一路传过来不会像RNN那样梯度消失得厉害。但LSTM有个毛病对局部特征不敏感或者说它抓取局部模式的能力太弱序列里的毛刺、突变、短周期形态经常被它平滑过去。CNN则是完全相反的脾气。卷积核在序列上滑动本质就是在做局部模式匹配特别擅长提取短期特征比如波形里的尖峰、平台的转折、周期的小片段。但CNN天生没有记忆卷积窗口往外一挪前面的事就跟它没关系了全局依赖无从谈起。所以两件事合在一起就很自然了先用CNN把序列里的局部特征抽出来把形状这个东西学好再把抽出来的特征序列喂给LSTM让LSTM去学这些特征之间的时序依赖。这就是CNN-LSTM混合结构的核心逻辑。放到Matlab里做这件事好处在于不用像Python那样折腾一堆环境一个工具箱基本能搞定数据导入、网络构建、训练验证整条链路。这篇分享不是我凭空写的。我最近在做一个基于历史数据做短期趋势预测的小项目数据量不大几千条带时间戳的样本特征维度十几个用纯LSTM做效果一般换成CNN提特征再接LSTM之后误差直接降了一个量级。整个过程用Matlab跑通源码和数据我都整理好了文章里会把网络结构和关键代码全部拆开讲适合那些想在Matlab里快速上手CNN-LSTM做时序预测的读者。2. 动手前先把数据这关过了2.1 数据长什么样直接决定网络怎么搭我用的这份数据是某设备一段连续运行状态下的传感器记录字段包含时间戳、压力、温度、转速、流量等变量目标变量是下一时刻的某个关键指标。数据格式是.csv第一行是列名之后每行是一条记录时间步长均匀采样周期固定。拿到数据之后的第一件事不是写代码而是先看数据形态。用readtable读进来然后summary看每一列的缺失情况、取值范围、是否有NaN。这一步看似基础但特别重要。我见过不少人在数据里带着NaN直接做训练结果损失曲线乱跳最后还以为是网络结构出了问题。data readtable(sensor_data.csv); disp(summary(data));如果有缺失值最保守的做法是线性插值Excel里整理好的数据表格或多或少也有空档直接用fillmissing处理就行。注意插值要在归一化之前做否则插值结果已经被缩放扭曲误差会被放大。2.2 训练集和测试集的划分别踩了数据泄露的坑时序数据划分和普通分类数据不一样。cvpartition按比例随机打乱样本这件事在时序任务里是不能做的。你今天用的样本和第10天的样本之间有强时序关联随机打乱等于把未来信息泄漏给模型测试集就会变得虚假友好看起来指标很好实际一上线就崩。正确的做法是按时间顺序取前80%做训练后20%做测试中间不要有任何交叉。Matlab里可以直接切片% 前80%训练后20%测试 numTrain floor(0.8 * size(data, 1)); trainData data(1:numTrain, :); testData data(numTrain1:end, :);2.3 归一化的细节和滑动窗口的构建归一化在CNN-LSTM组合模型里尤其重要。CNN对输入尺度敏感LSTM里的sigmoid/tanh对输入范围也有严格限制数据范围差太大会让梯度更新困难。我用的是mapminmax把每个特征缩放到0到1之间。[X_train_norm, ps_X] mapminmax(X_train, 0, 1); [Y_train_norm, ps_Y] mapminmax(Y_train, 0, 1);注意mapminmax是按行操作的所以输入要转置。这一步容易漏第一次用的时候我就因为没转置结果归一化维度完全错误模型训了半天输出一片常数。滑动窗口构造是个更关键的步骤。CNN-LSTM的输入不是一条样本一条样本地喂而是喂一段连续序列让网络看到过去L步才能预测未来h步。这里L是回看窗口长度h是预测步长。窗口长度设为50步相当于用过去50个采样点的特征序列预测未来1个点的目标值。% 构造滑动窗口 function [X, Y] createSlidingWindow(data, inputSteps, predictSteps) numSamples size(data, 1) - inputSteps - predictSteps 1; X zeros(numSamples, inputSteps, size(data, 2)); Y zeros(numSamples, predictSteps); for i 1:numSamples X(i, :, :) data(i:iinputSteps-1, :); Y(i, :) data(iinputSteps:iinputStepspredictSteps-1, 1); % 第一列是目标 end end窗口长度这个超参数不能拍脑袋定。实测下来窗口太长模型容易学到太多冗余信息训练变慢且容易过拟合窗口太短局部特征不够CNN的优势发挥不出来。我试过20、30、50、80四种窗口50是效果和训练时间的最佳平衡点。这个结论仅针对我这批数据但思路是可以复用的先用小步长测试观察损失曲线再逐步加长找拐点。3. 网络搭建的关键几行代码与背后的参数直觉3.1 核心layers逐层拆解网络结构一般是这样序列输入层 → 一维卷积层 → BN层 → ReLU → 池化层 → LSTM层 → 全连接层 → 回归输出层。Matlab里用layerGraph或者直接在trainNetwork里传layer数组就能搭。完整代码如下layers [ sequenceInputLayer(size(X_train_norm, 3), Name, input) convolution1dLayer(3, 32, Padding, causal, Name, conv1) batchNormalizationLayer(Name, bn1) reluLayer(Name, relu1) maxPooling1dLayer(2, Stride, 2, Name, pool1) convolution1dLayer(3, 64, Padding, causal, Name, conv2) batchNormalizationLayer(Name, bn2) reluLayer(Name, relu2) maxPooling1dLayer(2, Stride, 2, Name, pool2) lstmLayer(100, OutputMode, last, Name, lstm) fullyConnectedLayer(1, Name, fc) regressionLayer(Name, output) ];这里有几个细节值得展开讲一下它们直接决定训练成败。第一convolution1dLayer(3, 32)第一个参数是卷积核大小第二个参数是滤波器数量。核大小为3意味着每次只看连续3个时间步的局部特征这个数字类似三词词组太小学不到模式太大又失去了局部性。滤波器数量32起跳数量翻倍到64时模型表达能力增强但参数量也上去了训练时间变长。实测这个小数据集上32→64提升明显64→128收益开始递减就没再往上加。第二Padding参数。这里我特别选了causal填充模式这种填充保证卷积输出在时间步t只依赖输入时间步t及之前的信息不会向后看。这一点在做预测任务时至关重要——如果用了默认的same填充卷积层会把未来的信息混进当前特征里训练时模型相当于偷看答案测试时性能会骤降。第三中间加了两层卷积和池化的组合。池化层做降采样相当于对时间轴做了压缩让LSTM处理更浓缩的特征序列。但池化不能下压太狠因为LSTM需要保留一定的时间分辨率如果池化把序列压到太短LSTM能学到的时序信息就打了折扣。两层maxPooling1dLayer(2, Stride, 2)之后特征序列长度变为原来的四分之一这个压缩比在多数场景下都是合适的。3.2 为什么卷积层输出要接LSTM而不是直接全连接这个问题有朋友问过我如果CNN已经提取了特征为什么不直接接全连接层输出结果原因在于CNN抽取的特征编码的是局部空间/时间结构但最终预测的目标通常依赖整个时间上下文——全连接层只能做固定维度的映射处理不了长度动态变化或强时序依赖的信息。接LSTM就是为了让模型学会特征之间的先后关系。LSTM内部有门控机制输入门、遗忘门、输出门它在每个时间步会决定哪些特征信息要写入记忆、哪些旧记忆要遗忘、当前步要输出什么。这就像一个会做笔记的人——关键时刻把重要信息记下来不重要的及时忘掉最后总结时只把最重要的提出来。这就是lstmLayer(100)里100个隐含单元在做的事。3.3 trainNetwork训练选项里的门道网络结构搭好只是第一步训练选项里的参数同样重要。我用的训练配置如下options trainingOptions(adam, ... MaxEpochs, 100, ... MiniBatchSize, 64, ... InitialLearnRate, 0.001, ... LearnRateSchedule, piecewise, ... LearnRateDropFactor, 0.5, ... LearnRateDropPeriod, 20, ... Shuffle, never, ... Verbose, 1, ... Plots, training-progress);优化器选adam而不是sgdm因为adam自适应调整每个参数的学习率训练时序模型收敛更稳。**Shuffle设为never**是关键——时序样本一旦打乱序列的内在顺序就被破坏了虽然单条样本内的顺序还在但样本之间的时间依赖关系断裂了训练效果会明显劣化。学习率0.001是adam的默认推荐值但也要看数据规模。如果损失在训练初期就震荡不降把学习率降到0.0005试试如果收敛太慢可以微调到0.002。学习率衰减我用了piecewise每20个epoch减半这样训练后期可以更精细地逼近最优值。4. 训练过程的观察窗口哪些信号说明模型在稳健收敛4.1 损失曲线不是越低越好还要看验证集表现训练开始后Matlab会弹出训练进度窗口实时显示训练损失和验证损失如果配置了验证集。这里有个常见的认知陷阱训练损失降到很低不代表模型好用。真正需要关注的是训练损失和测试损失的差距。我这次训练中训练损失在前30个epoch快速下降验证损失却在一个平台期徘徊了十几个epoch之后才跟着下降。如果验证损失不再下降甚至往上走而训练损失还在降说明模型开始过拟合训练集了。一个实用的技巧是观察LearnRateDropPeriod附近的损失变化。学习率减半之后损失通常会出现一个小幅下降——如果这个下降不存在说明当前学习率下模型已经收敛再训也白搭。我这次在第60个epoch时验证损失出现了一次明显下降之后逐步平稳最终验证损失约为训练损失的1.2倍在合理范围内。4.2 对预测曲线别只看标签图像不像很多教程教人直接画预测值和真实值的对比图目测拟合程度。这个方法直观但容易被好像差不多骗过去。更严密的做法是逐点计算误差并查看误差随时间的分布情况。如果误差在某个时间段内系统性偏大说明预测在那段时间内没学好该阶段的特征。我在测试集上预测之后将结果反归一化再画了三张图全序列对比图、误差直方图、误差随时间变化散点图。全序列对比图看整体趋势是否跟随误差直方图看误差是否集中在零附近且大致对称误差散点图看是否存在误差结构性的漂移——如果误差有明显随时间增大的趋势模型很可能没有学到非平稳数据的趋势项需要检查数据预处理是否做了差分或趋势去除。4.3 训练时长和硬件占用别忽视CNN-LSTM相比纯LSTM训练时长会略有增加因为多了一层卷积特征提取。但这部分开销值得。我的实测数据是几千条样本窗口50步CPU训练大约5分钟一个epoch不到整体100个epoch在十几分钟内完成。如果在GPU上跑速度会有数量级提升。Matlab里可以通过gpuDevice查看可用GPU然后把数据用gpuArray转成GPU内存格式网络训练会自动调用GPU。这里有个注意点数据量小的时候GPU和CPU差异可能不明显甚至CPU更快因为数据在CPU和GPU之间传输也需要时间。样本量不上万CPU训练就够了不必非得追求GPU。5. 评估时最容易骗自己的三个指标陷阱5.1 RMSE和MAE的误读RMSE均方根误差对异常值敏感MAE平均绝对误差则更稳健。两者同时使用能更全面反映模型表现。这次训练完测试集RMSE大约是目标变量均值的5%MAE约为均值的3.5%从绝对值看模型表现还可以。但这里有个陷阱目标变量的量纲不一致时RMSE和MAE没有可比性。比如我在数据里也尝试预测过压力变量因为压力数值大量纲在几十~几百之间RMSE数值就显得很大而温度变量数值小RMSE就很小。不能拿一个RMSE去横向比较不同变量的预测效果必须看误差占该变量均值的百分比。更严谨的做法是算MAPE平均绝对百分比误差和R²决定系数。MAPE直接给出误差百分比R²衡量模型解释了多少方差。我这次的测试集MAPE在4.5%左右R²约为0.93对于短期趋势预测来说这个精度已经足以支撑实际使用了。5.2 数据泄露比模型差更可怕在做时序预测时一个隐蔽的错误是归一化的时候用了全部数据包括测试集的均值和标准差。mapminmax如果直接作用在整个数据矩阵上测试集的统计信息就已经悄悄渗透到了训练过程这会让最终评估结果好得反常。正确的做法是只用训练集的统计参数去做归一化测试集沿用训练集的结果。代码上这样实现% 先对训练集做归一化保存参数 [X_train_norm, ps_X] mapminmax(X_train, 0, 1); % 用相同的参数归一化测试集 X_test_norm mapminmax(apply, X_test, ps_X);这一点我在第一个版本里就做错了当时测试集R²高达0.97后来在写数据预处理函数时发现了问题修正之后R²下降到0.93但那个0.97是虚高的。这类数据泄露不会报错也不会产生警告只能靠自己对流程的审查来发现。5.3 单步预测和递推预测的差距必须心里有数我上面描述的配置是单步预测用过去50步预测未来1步。实际使用中很多场景要求多步预测这时如果直接拿预测值当作历史数据继续递推误差会随预测步长增长而累积放大。实测下来我这个模型在向前递推5步时MAPE从4.5%上升到约8%递推10步到13%左右。这个误差累积效应不是模型缺陷而是混沌系统的固有特性。如果业务确实需要更长时间范围的预测建议训练时就直接用多步输出结构把fullyConnectedLayer(1)改成fullyConnectedLayer(h)h即预测步长而不是递推。6. 我在调参过程中踩过的几个实坑与可复用的避坑经验6.1 学习率太高导致损失爆炸怎么定位第一次训练时我图省事把InitialLearnRate设成了0.01结果损失曲线在第5个epoch直接飙升到NaN。排查过程先检查数据是否有NaN没有再检查网络层是否有复数值虽然用了causal填充应该不会但一查还是没问题最后把学习率调回0.001正常了。结论很简单训练出现NaN首先怀疑学习率过高。尤其是在CNN和LSTM混合结构中不同层之间梯度尺度差异大过大的学习率容易让参数更新越过最优边界。如果0.001仍然不稳定可以试试0.0005再用学习率warmup策略前几个epoch从很小的学习率线性升到目标值。6.2 LSTM输出模式选last还是sequence区别很大lstmLayer有个OutputMode参数last表示只输出最后一个时间步的隐含状态sequence表示输出所有时间步的隐含状态。这个选择不是随意的如果后面接的是fullyConnectedLayer做最终预测选last就够了因为最后一步输出已经包含了整段序列信息的汇总如果后面还想再接一层LSTM做更复杂的时序建模则选sequence。我第一次搭网络时用的sequence再接全连接层训练时报维度不匹配。虽然可以通过fullyConnectedLayer前加一个flattenLayer解决但完全没有必要——白白增加计算量效果也没有更好。尽量让LSTM只输出最后一步。6.3 多特征输入时特征之间的量纲差异会让CNN学偏如果输入特征是温度压力转速流量这几个变量数值范围完全不同。温度可能是20-80压力可能是0.1-1转速可能上千流量可能是几百。如果不做归一化卷积核在提取特征时数值大的特征会主导梯度更新数值小的特征几乎学不到。统一归一化之后特征语义被保留数值范围变得可比。不过有一点要注意归一化对异常值比较敏感如果数据里有离群点mapminmax会把正常数据压缩到一个很小的区间。遇到这种情况可以先做分位数截断或剔除明显的坏点再归一化。6.4 训练集和验证集的划分比例时间序列有讲究分类任务里常用的80/20划分可能就够。但时序任务里如果测试集里包含了一小段完全不同于训练集的趋势突变那再好的模型也会表现很差这不代表模型有问题而是分布外预测本身就是不合理的期望。更合理的做法是按时间顺序三段划分——训练集70%验证集15%测试集15%。验证集用于early stopping测试集只用于最终评估。Matlab的trainingOptions里没有直接内建early stopping但可以通过ValidationData和OutputNetwork参数在验证集上保留最佳模型。6.5 训练数据太少时LSTM会过拟合到什么程度如果你手上只有几百条数据LSTM基本很难训好这个必须提前有心理预期。LSTM的参数量不小几百条样本很难支撑。我用过一份小数据集300条训练损失降到0.01测试损失却高达0.3典型的过拟合。解决办法有减少LSTM隐含单元数从100降到50、增加Dropout层dropoutLayer(0.2)、数据增强向训练序列中添加小幅噪声。Matlab中dropoutLayer可以直接接在LSTM后面用法和全连接层类似lstmLayer(100, OutputMode, last, Name, lstm) dropoutLayer(0.3, Name, dropout) fullyConnectedLayer(1, Name, fc)Dropout比例改在0.2-0.4之间比较常见。比例太大会让模型欠拟合太小没效果。我这次数据量在几千条不复用dropout也能训好但在更小的数据集上这一层是救命的。7. 这套源码怎么复用以及后续还能怎么扩展7.1 拿到源码后的快速替换步骤如果你拿到了这套源码和原数据想换成自己的数据需要改动的地方其实不多核心思路是四步把自己的数据整理成和sample_data.csv一样的格式第一列为时间戳可选中间列为特征最后一列为目标变量。在main.m中修改数据读取路径以及各特征列的索引值。根据自己数据的采样频率和周期特征调整滑动窗口长度inputSteps。查看目标变量的数值范围调整fullyConnectedLayer的输出维度单步预测为1多步预测为h。这四步改完基本就能跑通。网络结构部分不需要动除非数据特征维度差异极大那种情况才需要调整卷积层的滤波器数量。7.2 改成多步预测的具体做法如果想把单步预测改成多步有两个方向。方向一输出端改多输出fullyConnectedLayer(h)训练标签也改成未来h个时间步的目标值这种方式适用于h较小如2-10步的情况。方向二用sequence-to-sequence结构编码器LSTM读入一段序列解码器LSTM逐步生成未来值这种方式适合更长预测范围但代码复杂度明显更高。我个人的建议是先从方向一开始改动量小效果容易验证。方向二适合后期优化确认方向一的基线效果不够好时再上。7.3 更进一步在LSTM阶段加入注意力机制的思路CNN-LSTM已经能处理不少时序预测问题了但如果你发现模型在长序列上还是有早期信息遗忘的问题可以在LSTM输出后接一个简单的注意力层。注意力机制的本质就是不把所有时间步的信息等权对待而是学习一个权重向量让模型重点关注历史序列中那些与当前预测最相关的时间步。Matlab用custom layer来实现注意力机制需要重写一些底层函数比直接堆网络层要复杂一些。但思路是明确的从LSTM拿到所有时间步的输出OutputMode设为sequence然后通过训练学习一个权重向量对所有时间步输出做加权求和再送给全连接层。这个结构在小数据集上提升有限但在更长的序列上收益明显。7.4 超参数搜索的偷懒技巧手动调参的效率太低了。Matlab自带的bayesopt可以做贝叶斯超参数优化选中的参数包括窗口长度、LSTM隐含单元数、初始学习率、卷积核数量。虽然跑起来比较耗时但它能自动找到一组不错的参数组合。如果你不想花时间研究bayesopt的API还有一个土办法先用一组基础参数跑通流程然后只动一个参数记录结果再动下一个。这种单变量搜索虽然粗暴但在数据集不太大时完全够用。我把源码整理成了main.m、createSlidingWindow.m、train_cnn_lstm.m、evaluate_model.m几个模块每个模块职责单一用起来很清楚。原数据和结构都放在了一起拿到直接就能复现整个流程。你在自己的数据上试跑的时候如果损失曲线表现和预期不符重点检查数据预处理部分有没有做归一化和划分这两步出问题的概率最大。本文还有配套的精品资源点击获取