先说一个我最近经常被问到的问题:“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(trainLen+1: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:i+winSize-1); Y(i,:) = seq(i+winSize:i+winSize+horizon-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 |
|---|---|---|---|---|
| 基线A | 3宽 / 8个滤波器 | 0.01 | 32 | 0.283 |
| 方案B | 9宽 / 16+32 | 0.01 | 32 | 0.174 |
| 方案C | 9宽 / 16+32 | 0.005 | 64 | 0.121 |
| 方案D | 9宽 / 16+32,加BN | 0.003 | 64 | 0.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 padding='same'带来的边缘效应
在图像任务里,padding='same'是很常见的设置,它的目的是让卷积之后的特征图尺寸不变。但在时间序列里,这个“补零”是有代价的:在窗口的左右边缘,卷积核有一截实际上是踩在补出来的零值上,而不是真实数据上,导致边缘位置的特征响应失真。
更麻烦的是,如果我们做的是“预测未来”,那么从因果性的角度讲,卷积核只能看过去的信息,不能提前看到“未来”的补零或填充值。还是那句老话:预测必须因果。你用窗口最后一个点的位置做预测,如果卷积核在窗口尾部为了凑尺寸而看了看不见的信息,这在离线训练时可能没感觉,上线部署之后就会出现离线效果好、线上漂移大的怪现象。
我的解决方案有两个。第一,架构层面:如果你用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 | 隐状态传输距离远 |
| 样本量大、有GPU | CNN-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做同样的缩放,否则模型在你的服务器上很快就会“水土不服”。数据管道整理清楚了,剩下的就是慢慢调参,别指望一稿就能跑到最低误差。