先说一个我最近经常被问到的问题“CNN不是用来做图像的吗怎么还有人拿它做时间序列预测” 这个问题确实很典型但如果你把“空间卷积”换成“时间轴上的滑动窗口”就会发现卷积神经网络对局部趋势的提取能力一点不比图像领域弱。我最近在Matlab里完整跑通了一套基于CNN的时间序列预测程序从数据切分、网络搭建、训练调参到多步未来趋势预测全部落地准确度比我一开始用的线性回归方法明显高了一个台阶。这篇文章就把整个思路、代码、调优过程和踩过的坑讲清楚适合正在做短期预测、或者想学深度学习但不知道从哪入手的同学。这类程序的实际用途很广车间传感器的振动趋势、某个城市片区的用电负荷、设备温度异常前面的小幅漂移只要你能把历史数据组织成“一段窗口预测未来几个点”的形式CNN就能派上用场。我下面用的是一份仿真序列做演示保证你复制到Matlab里能直接跑跑完再换成自己的数据也没问题。1. 为什么时间序列预测会轮到CNN上场1.1 CNN不是图像专属时间轴上的卷积同样成立很多人的第一个反应是卷积不是专门用来扫图片的吗确实在图像任务里卷积核在像素矩阵上横向、纵向滑动识别边缘、纹理、形状。但你别忘了一维信号也有同样的结构。语音识别里很早就用一维卷积直接处理波形医学信号处理里也会用CNN看心电图的形态变化。时间序列本质上就是一维信号卷积核从“二维方块”缩成“一维线段”照样能滑。所以我做这套程序的时候并没有刻意追求新网络而是选择把问题形式化一个长度为64的窗口数据你可以把它想成一张“宽64、高1、通道1”的伪图像。然后让卷积核在这个宽度方向上一格一格滑动提取局部片段里的模式。这个视角在Matlab里特别方便因为深度学习工具箱里的图像卷积层可以直接用不用自己写底层运算。1.2 一维卷积究竟在“看”什么To真正理解CNN为什么能预测你得知道卷积核在干什么。一个卷积核本质上是一组权重比如长度9的核就是9个数字。它在窗口上每移动一步就做一次“对应位置相乘再累加”产生一个输出值。你可以把它理解成它正在找“窗口里有没有一小段形态和我这9个权重长得像的区域”。这句话非常重要。因为时间序列的趋势预测说到底就是找“历史里出现过哪些形态之后通常会跟着什么样的走势”。比如温度序列里出现了一段持续爬升的小斜坡卷积核如果学到了“爬升斜坡”这个模板它就会在输出特征图上被激活。继续往后连接几层卷积模型就能组合出更复杂的模式“倾斜爬升小幅回踩”之后往往还有一波加速。多来几个卷积核就相当于同时用几十个不同的模板在信号里扫描。有的负责抓尖峰有的负责抓平台期有的专门响应周期性的波峰波谷。这样一说你应该明白CNN做时间序列预测并不是玄学它就是用一堆可学习的局部特征去拟合“历史形态与未来结果”之间的映射关系。1.3 什么序列适合CNN什么不适合但我得先泼一盆冷水CNN不是所有时间序列问题的万能药。它擅长的是有局部形态、有短周期规律的序列。比如设备的振动信号一个轴承磨损初期会反复出现特定频率的小脉冲这个模式非常适合卷积核去捕捉。再比如用电负荷每天早晚都有相似的峰谷形态CNN可以很轻松地学会这段“前几天同一时刻的局部形态往往能预示未来这一小时的走向”。反过来如果序列接近随机游走今天的涨跌和过去形态几乎没有关系那CNN再深也白搭。还有些长程依赖特别强的数据比如语言模型要参考上下文几十个字靠一个大卷积核硬抓很难这时候LSTM或Transformer更合适。所以后面我在第5章会专门聊CNN和LSTM的选择这里你只需要记住一句话预测精度真正的天花板是数据里确定性的信号占比不是网络结构有多花哨。2. 动手前必须处理好的数据工程滑动窗口与归一化2.1 滑动窗口到底滑多大三条实用经验网络结构再猛数据组织错了也白搭。时间序列预测里最核心的一步就是把原始一维序列切成“输入窗口 标签窗口”的样本对。我这里用winSize表示输入窗口长度horizon表示要预测的未来步数。理论上winSize越长模型看到的历史信息越多但样本数量反而变少计算量也变大。这个平衡需要你自己试。我一般按照下面三条经验起步场景窗口长度建议理由序列有明显周期至少覆盖1到2个完整周期让卷积核有机会看到周期内部完整形态无先验知识winSize取2倍到4倍的horizon短期预测不需要看太多历史先保证样本量样本量特别紧张winSize尽量不超过总长度的10%每多1个窗口长度样本就减少1个你还可以用自相关函数辅助判断。画一下自相关图看过去多少步的延迟还有显著相关性就把它作为窗口长度的参考值。我用过一个例子某段仿真数据的主周期大约是50个采样点左右直接把winSize定为64相当于覆盖了一个多一点周期效果比32好不少。还有两个容易犯的低级错误我提前提醒你相邻窗口之间重叠太严重训练集和验证集容易出现“信息串门”后面第4章细说。horizon太大预测远期本身就不可靠标签里掺了大量噪声模型很难学。宁可做多步递归也别一上来就预测未来200个点。2.2 归一化的正确姿势先切分再算统计量初学者最喜欢犯的错是把整段序列的均值和标准差算完后再切分训练集测试集然后统一做归一化。这样看起来没问题实际上已经埋下了数据泄漏的隐患。因为测试阶段你用的均值、标准差里包含了测试段未来的信息相当于考试时提前看了一眼答案的难度范围。正确的顺序是先按时间切分只用训练段计算mean和std再用同一组参数分别变换训练段和测试段。写成Matlab代码就是这样% 正确训练、测试先切分统计量只从训练段计算 trainLen 1500; trainData data(1:trainLen, 1); testData data(trainLen1:end, 1); mu mean(trainData); sigma std(trainData); trainN (trainData - mu) / sigma; testN (testData - mu) / sigma;这里我特别强调一下很多人问我为什么归一化之后预测效果更好。核心原因并不是深度学习“喜欢”0到1的数据而是它能让梯度下降收敛得更顺。数据尺度一大一小同一层里不同特征的梯度可能差几个数量级训练就容易震荡甚至发散。把数据统一到均值0、方差1附近优化问题就变得“圆润”了。2.3 多步预测的两种输出设计直接输出和递归输出预测未来3步、5步还是10步有两种设计思路它们的本质区别在误差怎么累积。直接多步输出就是让网络最后一层同时输出horizon个节点一次性给出未来5步的预测。这种做法的好处是误差不累积第1步错了不影响第2步训练也简单。缺点是标签窗口里的远期点本身噪声大模型学起来困难而且horizon一旦设大输出层参数暴增。递归多步预测则是先预测第1步把预测结果拼到窗口末尾再拿这个“带预测值的窗口”去预测第2步像滚雪球一样滚下去。好处是可以任意延长预测距离缺点是误差会一步比一步大。我自己的习惯是短期预测用直接多步超过10步用递归或者干脆两个方案都跑对比一下谁更稳。这两种方式也可以混合使用。比如先直接预测未来5步把这5步作为“伪历史”再递归往后推10步。很多实际项目里我就用这个折中方案既能控制近端精度又能延展预测视野。不过在下面第3章的Demo里我先把直接多步输出跑通原理清楚了再改递归也不迟。3. Matlab完整实现一个可直接改用的1D-CNN预测程序3.1 为什么先用仿真数据跑通全流程我见过太多人拿到真实数据之后第一反应就是把网络堆上去结果损失曲线乱跳又找不到原因最后抱怨深度学习不靠谱。真实数据里可能有缺失、异常值、多个周期叠加这些都会干扰你对模型本身的判断。所以我的习惯是先造一份“确定性成分占主导、加一点噪声”的仿真序列把整个流程跑通确认网络、参数、评价指标都没问题再切换到真实数据。下面这段代码生成一个含有两个正弦周期成分并叠加少量高斯噪声的序列。之所以选正弦叠加是为了模拟很多行业数据里的周期性波动特征。% 固定随机种子保证实验可复现 rng(42); n 2000; t (0:n-1); signal 0.8 * sin(2*pi*0.02*t) 0.3 * sin(2*pi*0.005*t); noise 0.05 * randn(n, 1); data signal noise;我用2000个点前1500个做训练后500个做测试。这样切分的好处是验证集严格晚于训练集更贴近真实“用历史预测未来”的场景。3.2 数据准备与网络搭建核心代码逐块讲先定义一个构建滑动窗口样本的函数单独保存成buildSamples.m文件。输入是一维序列输出是4D特征数组X和标签矩阵Y。X的维度是winSize×1×1×numSamples前两维对应伪图像的宽和高第三维是通道数第四维是样本数这个结构正好能被imageInputLayer接收。function [X, Y] buildSamples(seq, winSize, horizon) numSamples length(seq) - winSize - horizon 1; X zeros(winSize, 1, 1, numSamples); Y zeros(numSamples, horizon); for i 1:numSamples X(:,1,1,i) seq(i:iwinSize-1); Y(i,:) seq(iwinSize:iwinSizehorizon-1); end end然后在主脚本里调用这个函数winSize取64未来预测步数horizon取5。winSize 64; horizon 5; [Xtrain, Ytrain] buildSamples(trainN, winSize, horizon); [Xtest, Ytest] buildSamples(testN, winSize, horizon);接下来是网络结构的定义。我用的是“卷积批归一化ReLU池化全连接”的经典结构。第一层卷积核宽度取9相当于在窗口上扫描长度为9的局部形态第二层卷积核宽度收窄到5做更精细的组合特征提取。滤波器数量从16增加到32让高层可以表达更丰富的特征组合。layers [ imageInputLayer([winSize 1 1], Normalization, none, Name, input) convolution2dLayer([9 1], 16, Padding, same, Name, conv1) batchNormalizationLayer(Name, bn1) reluLayer(Name, relu1) maxPooling2dLayer([2 1], Stride, [2 1], Name, pool1) convolution2dLayer([5 1], 32, Padding, same, Name, conv2) batchNormalizationLayer(Name, bn2) reluLayer(Name, relu2) maxPooling2dLayer([2 1], Stride, [2 1], Name, pool2) fullyConnectedLayer(64, Name, fc1) reluLayer(Name, relu3) fullyConnectedLayer(horizon, Name, fcOut) regressionLayer(Name, reg) ];我强调三个容易忽略的地方。第一个是imageInputLayer里的Normalization设成none因为我们已经手动做过z-score归一化不需要网络再算一遍免得重复处理导致分布信息错乱。第二个是卷积层之间我加了批归一化层它能够让每次迭代的特征分布更稳定收敛速度明显加快。第三个是池化层我只用核大小2因为时间序列很多细节信息很脆弱池化窗口太大容易把尖峰信息抹掉。3.3 训练配置优化器、学习率、批大小怎么定训练选项我直接给出一个经过验证的组合options trainingOptions(adam, ... InitialLearnRate, 0.005, ... MaxEpochs, 100, ... MiniBatchSize, 64, ... GradientThreshold, 1, ... Shuffle, every-epoch, ... Plots, training-progress, ... Verbose, true); net trainNetwork(Xtrain, Ytrain, layers, options);这里逐项说一下理由。优化器选adam基本是深度学习默认项它对学习率的敏感度比SGD低很多适合我们这种不想精细调参的工程场景。初始学习率0.005不算高也不低这里有个判断技巧如果损失曲线在前几个迭代基本不动可以把学习率乘10分之一再试如果震荡剧烈就再除以10。GradientThreshold设为1是为了防梯度爆炸。时间序列任务里如果某个样本的标签值因为噪声变得特别极端梯度的范数会突然飙高导致训练一步崩盘。梯度截断就是告诉求解器“你这一步最多迈这么大”。MiniBatchSize选64是平衡显存和梯度的稳定性。批大小太小梯度噪声大损失曲线容易抖批大小太大一轮迭代太慢而且容易收敛到偏平的局部极值。64对我这份数据来说很舒服。3.4 跑通之后的高精度调优我如何把RMSE压下去模型跑通之后真正的调优才刚开始。我在同一份仿真数据上固定随机种子记录了这么一组对比结果你感受一下差别方案卷积核/滤波器学习率MiniBatch测试RMSE基线A3宽 / 8个滤波器0.01320.283方案B9宽 / 16320.01320.174方案C9宽 / 16320.005640.121方案D9宽 / 1632加BN0.003640.096基线A的问题很明显卷积核太窄、滤波器太少网络只能看到极局部的片段还要在这么多噪声里去学模式自然学不好。方案B把卷积核加宽到9感受野变大之后能够覆盖一段完整的斜坡或者半个波峰RMSE立刻降了一大截。这印证了“窗口和卷积核应该与数据特征尺度匹配”的道理。方案C把学习率从0.01降到0.005、批大小加到64之后训练损失曲线明显平稳了。前段时间总是看学习曲线的朋友应该懂学习率大但批大小小时损失曲线像锯齿一样很难确定到底收敛没有。方案D继续微调学习率并加BN最后拿到0.096的RMSE。真实数据上数字会不一样但调参逻辑是一致的先调感受野再调训练稳定性最后抠细节。预测完成之后记得把归一化结果还原回原始单位。否则你看到的RMSE是在标准化空间里的跟业务里的“度数”“毫米每秒”对不上不好向人解释效果。YPredN predict(net, Xtest); % 反归一化统计量来自训练段 YPred YPredN * sigma mu; YtestReal Ytest * sigma mu; rmse sqrt(mean((YPred - YtestReal).^2, all)); mae mean(abs(YPred - YtestReal), all); fprintf(RMSE %.4f, MAE %.4f\n, rmse, mae);4. 踩过的坑边缘效应、训练不稳定与假性高精度4.1 paddingsame带来的边缘效应在图像任务里paddingsame是很常见的设置它的目的是让卷积之后的特征图尺寸不变。但在时间序列里这个“补零”是有代价的在窗口的左右边缘卷积核有一截实际上是踩在补出来的零值上而不是真实数据上导致边缘位置的特征响应失真。更麻烦的是如果我们做的是“预测未来”那么从因果性的角度讲卷积核只能看过去的信息不能提前看到“未来”的补零或填充值。还是那句老话预测必须因果。你用窗口最后一个点的位置做预测如果卷积核在窗口尾部为了凑尺寸而看了看不见的信息这在离线训练时可能没感觉上线部署之后就会出现离线效果好、线上漂移大的怪现象。我的解决方案有两个。第一架构层面如果你用convolution1dLayer可以把Padding设成causal确保每个时间步只用当前时刻及之前的信息这是最规范的做法。第二如果像Demo里一样用convolution2dLayer模拟一维卷积那就把窗口构造得宽一些让目标预测点落在窗口的中后段避免把“答案”紧贴在边界上。4.2 损失曲线看着没问题但验证RMSE很高聊聊假性高精度有一种更隐蔽的坑叫“假性高精度”。我一开始没有切分就直接算归一化参数然后随机把样本打乱按7:3分成训练验证集结果验证集RMSE低到0.06几乎是完美预测。我当时还有点兴奋换到真正未来的测试集上一测立刻变成0.2以上基本打回原形。问题出在两处数据泄漏和样本重叠。第一处归一化参数用了全量数据训练过程间接“看”了未来分布的统计量。第二处相邻窗口重叠严重我再一shuffle验证集里有大量样本和训练集样本只差一两个时间步它们之间的信息几乎完全重复验证曲线自然好看。正确的做法是先切分再归一化验证集按连续时间块选取不要随机抽。交叉验证也要用滚动的方式例如用前一段训练、紧接着的后一段验证而不是随机洗牌。经过这些修正之后模型在真正未来数据上的RMSE虽然变大了但那是真实的精度你可以相信它。4.3 训练不稳定排查手册损失震荡、不下降、验证反弹我把自己在这类任务里常遇到的现象和应对办法整理成了一个小表遇到问题先对照它现象可能原因对策损失曲线剧烈震荡学习率太高或批大小太小学习率降低10倍批大小适当加大训练损失几乎不降学习率太低、特征尺度有问题检查归一化参数尝试提高学习率损失下降但验证反弹模型过拟合提前停止、增加训练数据、减小模型容量训练中途出现NaN梯度爆炸或数据有极端值加GradientThreshold检查标签里是否有Inf有一次我为了省时间把MiniBatchSize从64降到了16结果同样的学习率下损失曲线开始跳来跳去怎么都收敛不到之前的水平。后来我才想起来批大小变小之后梯度估计方差变大需要的学习率也得相应调低。深度学习里的参数不是孤立起作用的改一个就要重新看全局。5. CNN与LSTM的实战选择以及混合模型的扩展思路5.1 局部与记忆的取舍先打个比方。用CNN看时间序列像是拿着一个放大镜贴着信号看它很擅长观察一小段里的纹理和形态用LSTM看时间序列则更像一个记性特别好的朋友他记得很久之前的某次变动并且知道那次变动可能影响现在。CNN的“视野”由卷积核宽度和网络层数决定堆很多层也能扩大视野但参数会成倍增长。LSTM天生就有跨时间步传递信息的能力但训练更慢也更容易出现梯度问题。所以在选择模型前得先问自己你要预测的结果主要取决于过去多久的形态如果是基于最近一两个周期的局部形态CNN完全够用如果必须靠一个月前的某个状态来解释当前走势LSTM可能更合适。实际工程里很多问题的预测结果其实是局部原因和长程原因的叠加所以才有下面要说的混合结构。5.2 两类常见场景的实战判断场景特征推荐方向我的理由数据量不大几千条级CNN参数少不容易过拟合训练快序列有明确短周期CNN卷积核直接匹配周期形态依赖很久远的信息LSTM隐状态传输距离远样本量大、有GPUCNN-LSTM混合先提取局部特征再建模长程依赖需要解释预测依据CNN卷积核可视化相对直观我见过不少项目上来就想用LSTM理由是“RNN处理时间序列天经地义”。但如果数据量只有两三千条LSTM反而比CNN更容易发生过拟合。反过来CNN在长程依赖很强的文本预测任务里也会吃力。选型不能看名气要看数据形态。5.3 想进一步优化可以试试CNN-LSTM串联结构如果后面数据量大了或者需要更长时间范围的预测可以考虑把CNN和LSTM串联起来先用一维卷积从每个窗口里提取局部特征序列再把这段特征序列交给LSTM去建模长程依赖。用Matlab定义核心结构时大概长这样layers [ sequenceInputLayer(1, Name, input) convolution1dLayer(9, 16, Padding, causal, Name, conv1) reluLayer(Name, relu1) lstmLayer(32, OutputMode, last, Name, lstm) fullyConnectedLayer(horizon, Name, fc) regressionLayer(Name, reg) ];需要注意的是这种结构里的输入数据应该是cell数组格式的序列训练方式和前面纯CNN不太一样。我自己通常用dlnetwork配合自定义训练循环来做灵活性会高很多但复杂度也上来了。如果你的数据量还没到这个量级我建议先老老实实把纯CNN方案打磨好混合模型留作后续优化方向。最后再分享一个我自己保留了很久的习惯跑任何模型之前先画一张滞后图或者自相关图看看序列本身到底有没有可预测性。如果滞后图里几乎看不到任何结构那再好的网络也救不回来。另外模型练好之后记得把预处理参数和网络一起保存成.mat文件上线预测新样本时必须用训练阶段的mu和sigma做同样的缩放否则模型在你的服务器上很快就会“水土不服”。数据管道整理清楚了剩下的就是慢慢调参别指望一稿就能跑到最低误差。
阅读完成 · 觉得有帮助?