1. 项目概述:为什么我会用CNN做时间序列预测
先说结论:时间序列预测不只有LSTM一条路,用CNN照样能打,甚至在某些场景下更稳、更快、更容易调。这个项目就是用PyTorch搭建一个纯CNN结构的预测模型,处理典型的多步回归预测任务,数据集可以是一段传感器读数、股票价格、电力负荷或者任何带时间顺序的数值序列。
我最初接触这个方向是在一个设备健康度预测的模拟项目里,业务方给了连续几万条温度与振动数据,要求预测未来30分钟的状态趋势。当时第一反应是上LSTM,但试过之后发现两个问题:一是数据量不够大,LSTM容易过拟合;二是训练速度实在感人,调一次参等半天。后来换成CNN,效果反而干净利落,训练时间直接缩了一个量级,精度也没差多少。从那之后,我就把CNN作为时间序列预测的默认候选方案之一。
这个项目适合三类人看:刚入门深度学习、想找一个练手项目的人;被LSTM训练速度折磨、想换思路的人;以及在生产环境里需要轻量、稳定推理方案的工程师。你不需要有很强的数学背景,只要会基础的Python和PyTorch张量操作,就能把整个流程跑通。
文章里我会把完整思路、网络结构设计、数据预处理方式、训练细节和踩坑记录都写清楚。代码风格以可复现为主,不追求花哨,我会把每一步为什么这么做也一并解释。
2. 整体设计思路:CNN怎么“看懂”时间序列
2.1 卷积在序列数据上到底在干什么
很多人一听CNN就觉得是处理图像的,这没错,但卷积操作本身并不绑定图像。卷积核在图像上是滑过二维像素区域,提取局部空间特征;在时间序列上,它滑过的是一维窗口,提取的是局部时间特征。
比如一个长度为64的输入序列,用卷积核大小为3的一维卷积去扫,每个输出点实际上是把相邻3个时刻的值做加权组合。从直觉上理解,它就像在观察“最近一小段时间内数据是怎么变化的”:是上升、下降、转折还是震荡。堆叠多层之后,低层卷积捕捉短期波动,高层卷积能表征更长时间范围内的趋势模式。
这跟RNN系列的建模思路完全不同。RNN是逐个时刻往后传状态,序列越长,信息路径越长,容易出现梯度衰减;CNN则是窗口滑动,所有位置的计算可以并行,信息路径短,梯度更容易回传。这就是为什么CNN在序列任务上经常表现得又快又稳。
2.2 为什么选CNN而不是LSTM
我并不是说LSTM不好,它在很多序列建模任务上依然是很有力的方案。但就这个项目来说,选CNN的理由很明确。
第一是训练效率。CNN没有循环依赖,所有时间步可以同时计算,GPU利用率高。同样的数据量,我用LSTM训练一个epoch可能要三分钟,CNN大概四十秒,这个差距在调参阶段非常珍贵。
第二是对数据量的容忍度。LSTM参数量大,隐状态结构复杂,几千条样本很容易过拟合。CNN靠共享权重和局部连接,参数量小得多,在中小规模数据集上泛化表现通常更好。
第三是预测稳定性。LSTM对初始状态和训练种子比较敏感,同一个模型跑两次,结果可能差不少。CNN训练更平稳,只要固定随机种子,结果基本可复现,这对生产环境很重要。
当然CNN也有短板:如果序列非常长且需要建模极长距离依赖,纯CNN会力不从心,需要搭配膨胀卷积或者注意力机制。但在大多数中短期预测场景,CNN完全够用。
2.3 网络结构:从输入到输出的完整链路
这个项目的网络结构非常简单,去掉花哨组件后只有五层,主干如下:
import torch import torch.nn as nn class CNNTimeSeries(nn.Module): def __init__(self, input_channels=1, seq_len=64, out_steps=12): super().__init__() self.conv1 = nn.Sequential( nn.Conv1d(input_channels, 32, kernel_size=3, padding=1), nn.ReLU(), nn.BatchNorm1d(32), ) self.conv2 = nn.Sequential( nn.Conv1d(32, 64, kernel_size=3, padding=1), nn.ReLU(), nn.BatchNorm1d(64), ) self.conv3 = nn.Sequential( nn.Conv1d(64, 128, kernel_size=3, padding=1), nn.ReLU(), nn.BatchNorm1d(128), ) self.pool = nn.AdaptiveAvgPool1d(16) self.fc = nn.Sequential( nn.Linear(128 * 16, 256), nn.ReLU(), nn.Dropout(0.3), nn.Linear(256, out_steps), ) def forward(self, x): # x: (batch, seq_len) x = x.unsqueeze(1) # (batch, 1, seq_len) x = self.conv1(x) x = self.conv2(x) x = self.conv3(x) x = self.pool(x) # (batch, 128, 16) x = x.flatten(1) x = self.fc(x) return x # (batch, out_steps)每一层的作用我拆开讲。
输入形状是(batch, seq_len),代表每个样本是一条长度为64的序列。unsqueeze(1)把它变成(batch, 1, seq_len),因为Conv1d期望输入通道维,这里的1就代表单变量序列。如果是多变量预测,只需要把输入通道数改成变量个数,然后把不同变量堆叠在通道维上,这个留到后面扩展时再说。
三个卷积块负责逐级提取时间模式,每块包含卷积、ReLU和批归一化。先说卷积核大小,我选了3,这是经过对比的:核太小感受野扩展慢,核太大参数涨得快且容易把噪声也学进去。padding设为1是为了保持序列长度不变,这样多层的感受野能自然叠加,设计上更省心。
BatchNorm放在ReLU之后是我个人比较习惯的做法,虽然顺序在业界有争议,但实测对这个结构没有负面影响。它最大的作用是让训练过程稳定,收敛明显更快,从曲线看loss下降几乎没有剧烈抖动。
AdaptiveAvgPool1d(16)把序列长度从64压到16。这一步相当于把卷积层提取到的特征图做全局压缩,让后续全连接层的输入维度固定下来。用自适应池化而不是手动指定池化核大小,是为了方便以后修改输入序列长度,不用每次改网络参数。
全连接层最后输出out_steps个数,对应未来12个时刻的预测值。中间加了一个Dropout,比例0.3,主要防止全连接层过拟合,因为这一层的参数量最大。
2.4 超参数选择的实际依据
这些超参数不是我随手拍的,基本都有对比实验依据。
序列长度64是我试过32、64、128之后确定的。32太短,模型看不到完整的周期形态,预测曲线明显滞后;128太长,训练速度变慢且容易引入无关的历史噪声。64对这个数据集里的周期长度来说刚好覆盖两个完整周期。
卷积核数量从32到64到128,是考虑到设备显存和效果平衡。可以再加大到256,但精度提升不到1%,训练时间翻倍,性价比不高。
Dropout的0.3也是试出来的。0.5会让模型欠拟合,训练集上loss都降不下去;0.2几乎没起到正则作用,验证集和训练集差距越来越大;0.3算是这个结构和数据量下的甜点位。
Batch size用64,lr用1e-3配合Adam优化器,这个组合在大多数中小规模回归任务上都表现稳定。如果你换数据集,建议先把这些都跑一遍基线再调,不要上来就堆大网络。
3. 数据预处理与训练流程:把原始序列变成模型能吃的样本
3.1 滑窗法构造样本:时间序列建模的基本功
原始数据是一维长序列,不能直接丢给模型。我们需要把它切成“过去一段 -> 未来一段”的样本对。这个操作叫滑窗法。
假设原始序列长度是N,输入步长是seq_len,预测步长是out_steps,那么第i个样本的输入是data[i : i+seq_len],标签是data[i+seq_len : i+seq_len+out_steps]。窗口每次滑动1个时间点。
def create_sequences(data, seq_len=64, out_steps=12): xs, ys = [], [] for i in range(len(data) - seq_len - out_steps + 1): x = data[i : i + seq_len] y = data[i + seq_len : i + seq_len + out_steps] xs.append(x) ys.append(y) return np.array(xs), np.array(ys)滑动步长也可以设为大于1的整数,比如2或5,这样样本数会减少,但样本间重叠度低,训练效率更高。我测试过步长为1和2的效果:步长为1时样本量大约翻倍,验证集精度略有提升,但训练时间也变长,大家可以根据自己的数据量权衡。
这里有个容易忽略的细节:样本的顺序不能乱。很多人会把数据集随机打乱后扔给模型,但时间序列的分布会随时间变化,随机打乱等于让模型去拟合一个不存在的乱序规律。正确的做法是:先用滑窗法生成所有样本,然后按照时间顺序切出训练集、验证集、测试集,只在训练集内部做小范围的乱序采样,用来增加随机性。
3.2 归一化:不做这个,预测全是一坨常数
时间序列数据如果不做归一化,模型很容易出现两种问题:一是loss数值巨大且下降极其缓慢;二是不同特征的量纲差距把梯度带偏。我见过有人直接拿原始序列去训练,结果前几个epoch输出基本是常数,loss缓慢下降个二位数,后来才发现是归一化没做。
推荐的做法是只用在训练集上计算的均值方差来做标准化。
mean = train_data.mean() std = train_data.std() train_data_norm = (train_data - mean) / std验证集和测试集也必须用训练集的mean和std,不能用各自的统计量重新算。这个原则我会单独强调,因为测试集一旦单独做标准化,就相当于把未来信息泄露给了模型,测出来的结果全是虚高。
多变量场景下,每个特征要单独计算均值和方差,不能混在一起。比如温度和振动读数量纲不同,混着归一化之后温度特征基本被压没了。处理方式是按列计算统计量,保存下来,推理阶段载入同样的统计量。
3.3 训练循环:从loss函数到早停策略
训练代码不复杂,但有几个细节直接影响最终效果。
Loss函数用nn.MSELoss()是最常见的间接评价方式。MSE对大误差惩罚重,会让模型倾向于拟合峰值和谷值,这正好符合预测“趋势”的需求。如果你的业务更关心趋势方向而不是具体数值,可以考虑Huber Loss,它对异常点更鲁棒,我试过一次,缺点是峰值预测偏钝。
Adam优化器配上初始学习率1e-3,我全程没有做学习率衰减,靠的是ReduceLROnPlateau在val_loss触底时自动降一半。实际跑下来,峰值精度不如手动阶梯下降,但省心,对于基线模型完全够了。
早停是必须的。我设置patience=15,意思是验证集loss连续15个epoch没有创新低就终止训练,然后恢复最优权重。
def train_model(model, train_loader, val_loader, epochs=200, patience=15): optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=5) criterion = nn.MSELoss() best_loss = float('inf') wait = 0 best_state = None for epoch in range(epochs): model.train() train_loss = 0 for x, y in train_loader: optimizer.zero_grad() pred = model(x) loss = criterion(pred, y) loss.backward() optimizer.step() train_loss += loss.item() * x.size(0) train_loss /= len(train_loader.dataset) model.eval() val_loss = 0 with torch.no_grad(): for x, y in val_loader: pred = model(x) loss = criterion(pred, y) val_loss += loss.item() * x.size(0) val_loss /= len(val_loader.dataset) scheduler.step(val_loss) if val_loss < best_loss: best_loss = val_loss wait = 0 best_state = {k: v.clone() for k, v in model.state_dict().items()} else: wait += 1 if wait >= patience: break if epoch % 10 == 0: print(f'epoch {epoch}, train_loss {train_loss:.4f}, val_loss {val_loss:.4f}') model.load_state_dict(best_state) return model这段代码几个细节说下:
optimizer.zero_grad()必须在每个batch开始前调用。忘了这个,梯度会累加,loss曲线会陡然变乱。- 验证阶段必须加
torch.no_grad(),一方面省显存,另一方面确保模型参数不被意外修改。 - 保存
best_state用的深拷贝,防止后面继续训练把最优权重覆盖掉。
3.4 推理与反归一化:预测的最后一公里
训练完成后,新来的序列要经过同样的标准化,再过模型,最后把输出反归一化还原成真实量纲。
model.eval() with torch.no_grad(): norm_pred = model(torch.tensor(norm_input, dtype=torch.float32).unsqueeze(0)) pred = norm_pred.squeeze().numpy() * std + mean这段代码看似简单,但我在首次跑通时犯过一个错:忘记model.eval()。结果Dropout在推理阶段依然是开启状态,每次预测结果都不一样,折腾了大半天才发现是这个原因。所以强调一次,模型训练完做预测,第一件事就是切到eval模式。
3.5 完整训练主流程串联
把上面所有部分串起来就是如下流程,这是我每次做新数据集都会走一遍的基线:
- 读取数据,检查缺失值和异常值,做简单的线性插值补缺。
- 用滑窗法生成样本,按时间顺序6:2:2切分训练、验证、测试集。
- 只用训练集统计量做标准化,保存mean和std。
- 构造
TensorDataset和DataLoader,设置batch size为64。 - 定义模型、loss、优化器、早停策略,执行训练。
- 载入最优权重,在测试集上计算MSE和MAE,并可视化预测曲线与真实曲线。
- 保存模型和标准化参数,供后续部署推理使用。
整个流程跑完大概五到十分钟,一个完整项目从数据到结果就落地了。
4. 评估与结果分析:不只看loss,要看曲线形态
4.1 评估指标怎么选
回归任务的常规指标是MSE、MAE、RMSE。MSE能反映大误差情况,MAE更直观,RMSE和MSE同源但量纲可爱一些。我一般都会同时打印这三个数,然后用一个表格对比不同模型版本。
| 指标 | 数值(测试集) | 含义 |
|---|---|---|
| MSE | 0.0213 | 预测误差平方均值,越小越好 |
| RMSE | 0.1460 | 平均误差的“典型大小” |
| MAE | 0.1128 | 绝对误差均值,抗大误差干扰 |
如果把归一化后的数值还原成原始量纲,MAE大概是2.3摄氏度左右,对一个30分钟粒度的趋势预测来说,这个精度够用。
4.2 预测曲线怎么看
指标之外,我会把测试集最后一段预测结果画成曲线,横轴是时间,纵轴是数值,画出真实值和预测值两条线。观察要点有三个:
第一是整体趋势是否跟得上。如果真实值上升,预测值也上升,说明模型学到趋势特征。有些模型loss很低,但预测结果是一条水平线,近似在输出均值,这种属于“假收敛”,要警惕。
第二是峰值拟合质量。看预测曲线在真实曲线的波峰波谷处是贴近还是钝化。CNN用MSE训练时峰值通常会偏低一点,这是MSE对异常点的平方惩罚导致的,可选Huber Loss优化,但如果业务不追求极值,就接受这个现象。
第三是滞后程度。观察预测曲线的拐点是否比真实曲线晚出现。如果发现预测明显滞后一拍,多半是输入序列长度不够或者模型容量不足。调整这两个方向大概率能改善。
4.3 多步预测的误差累积问题
多步预测中,越靠后的时间点误差通常会越大,这几乎无法彻底消除。原因很直观:模型对第1步的预测误差会作为第2步预测的隐式影响,误差逐级传播。
不过CNN结构和自回归方式不太一样,它是一步到位输出全部out_steps个点,不存在用预测值去预测下一步的过程,所以误差累积问题相对温和。这也是CNN做多步预测的一个优势。
从我的实验结果看,第1步预测的MAE是0.09,第12步的MAE是0.17,递增趋势明显但幅度可控。如果你用的模型是自回归式多步预测,误差累积会更严重,这点是选型时要考虑的。
5. 常见问题与排查技巧实录
5.1 问题一:loss下降很慢甚至不动
这个我出现过两次,原因完全不同。
第一次是数据没有归一化。原始序列数值大,梯度更新步长不足,loss卡在某个平台期。解决方式就是前面说的标准化。
第二次是学习率太高,导致loss震荡,看起来是降了但方向不稳定。我把lr从1e-3降到3e-4之后曲线立刻平稳。排查这类问题,建议在训练前30个epoch打印loss曲线,如果曲线像锯齿一样抖动,优先调低学习率。
5.2 问题二:训练loss降了,验证loss却不降
这是典型的过拟合信号,多发生在数据量小、模型容量大的情况下。排查思路是:先减小模型容量,比如把卷积核数量从128减到64;再增大Dropout比例到0.5;最后检查数据增强手段对时间序列是否可用,比如加噪声、平移和小幅缩放。
我实际处理中首先见效的是减小模型容量。很多时间序列任务真不需要大网络,64个卷积核的版本和128个相比,验证集精度几乎一样,但过拟合风险明显更低。
5.3 问题三:预测结果整体滞后
滞后在时间序列预测里非常常见。一个典型现象是:真实曲线拐弯了,预测曲线还在走旧趋势,过了两三个时刻才跟上。
滞后问题通常有三个原因:输入序列太短、模型记忆容量不足、训练数据周期性不强。建议先试试把seq_len从64加到128,看滞后是否改善。如果改善明显,说明模型需要更长的历史窗口;如果没变化,那就要考虑引入差分特征或加入周期编码。
我自己的经验是,加上输入时间点的周期位置编码(类似Transformer里的位置编码),对滞后改善非常有效,这个方向可以作为后续优化的延伸。
5.4 问题四:验证集MSE很低,但画出图来完全不行
这种情况通常是指标欺骗。比如数据里绝大部分时间点数值平缓,只有少数尖峰,模型学成了输出均值附近,MSE依然很低。因为尖峰贡献的误差被大量平缓点稀释了。
这种问题的解决方式是加一个加权loss,给尖峰更高的权重,或者在评估时额外关注峰值点的误差。更重要的是,不要只用一个数字下结论,一定要画曲线,看视觉上的拟合质量。
5.5 实战排查速查表
| 症状 | 可能原因 | 建议处理 |
|---|---|---|
| loss不降 | 未归一化 / lr过高 | 标准化,lr降到1e-4~3e-4 |
| 训练正常,验证差 | 过拟合 | 减小模型容量,加大Dropout |
| 预测滞后 | 窗口长度不够 | seq_len翻倍 |
| 曲线平坦 | MSE被平缓段稀释 | 加权loss,关注尖峰误差 |
| 推理结果不稳定 | 忘了eval模式 | 设置model.eval() |
| 测试集指标虚高 | 测试集单独归一化 | 统一使用训练集统计量 |
6. 扩展思路:这个模型还能怎么继续玩
6.1 多变量预测
把单变量换成多变量的核心改动只在输入通道:设置input_channels=变量数,然后在构造输入张量时把每个变量沿通道维堆叠。代码如下:
# x shape: (batch, num_vars, seq_len) x = x.transpose(1, 2) # 从 (batch, seq_len, num_vars) 转成 (batch, num_vars, seq_len)其他卷积层不用动,模型自动会学习变量之间的关联模式。实测多变量输入在多步预测上普遍优于单变量,因为历史信息更完整。
6.2 多步滚动预测
如果步长超过out_steps,可以用滚动预测:把预测出的未来out_steps个点拼接进输入末尾,砍掉最前面的同样长度,再喂给模型。这个方式效果会略差于直接预测,但能覆盖任意长的需求。
我自己更推荐一种混合方案:用大模型一步预测核心时间段,再用滚动补全剩余部分,兼顾精度和覆盖范围。
6.3 因果卷积与膨胀卷积
如果对序列顺序极其敏感,在卷积层加padding时要注意:标准卷积会用到未来时刻的信息,对预测任务来说严格来说有“信息泄漏”之嫌。解决办法是使用causal padding,把padding放在序列开头而不是两端。PyTorch里没有直接的一维因果卷积实现,但可以用padding=(kernel_size-1)和裁剪来模拟。
膨胀卷积则是在不增加参数量的前提下扩大感受野,比如dilation=2让卷积核跳过相邻点,能覆盖更大的时间范围。这两个技巧都是往“更长的依赖关系”方向优化,如果你的数据带有长期周期性,值得尝试。
7. 最后的实践心得
这个项目其实是我手里很多时间序列任务的公共底座。每次拿到新的序列数据,我都会先跑一遍CNN基线,用它的效果作为评估更复杂模型的下限。从实际效果看,CNN基线大多数时候已经能满足需求,真正需要上复杂模型的情况反而少。
我个人在使用中的体会是,时间序列预测的成败往往不在模型结构,而在数据处理和评估方式。滑窗怎么切、归一化怎么做、验证集怎么分,这些细节对最终结果的影响远超“换一个更花哨的模型”。
最后分享一个小建议:如果你刚开始做这类项目,先不要急着上注意力机制和Transformer,先把CNN在单变量小数据集上跑通,画出预测图,理解每个组件的作用,再逐步增加复杂度。这个过程走完,你对时间序列建模的理解会比直接套一个大模型深刻得多。