☰
用CNN做时间序列预测:因果卷积、膨胀卷积与PyTorch实现
2026/10/21 5:23:43 网站建设 项目流程

1. 为什么考虑用CNN做时间序列预测

1.1 一维卷积如何与时间序列“对上眼”

提起时间序列预测,大多数人第一反应是LSTM、GRU这类循环神经网络。这个印象没错,RNN天生就是按时间步“一字一句读下去”的结构,序列里前后依赖关系确实适合它。但我在实际项目里用久了,慢慢发现RNN有几个让人难受的地方:训练慢,因为每个时间步必须串行计算,没办法充分利用GPU并行能力;调参敏感,学习率稍微给大一点,梯度就炸给脸色看;长序列下还得小心翼翼地处理梯度衰减,即使有LSTM的遗忘门,也不是一劳永逸。

后来有一次做某工业传感器的时间序列回归预测,数据量不算大但序列比较长,我用LSTM跑了半天,收敛速度迟迟不满意。当时旁边有个朋友建议我试试一维卷积网络,我心里其实挺怀疑的:卷积不是干图像的吗?怎么跟时间序列扯上关系?但试完之后我才发现,CNN做时间序列预测完全不是一个“凑合能用”的方案,而是一个在很多场景下真的能和RNN掰手腕的路线。

关键点在于,一维卷积(torch.nn.Conv1d)在时间维度上滑动,本质上做的是“局部模式提取”。时间序列里最常见的信号特征,比如突刺、趋势拐点、周期性片段,本质上就是一段固定长度的局部模式。卷积核做的事情,就像一把固定大小的“扫描窗”,在整条序列上滑过,遇到跟它参数适配的局部形状就激活响应。这跟你用眼睛看曲线走势是同一个思路:先看一小段长什么样,再看下一小段长什么样,然后把局部特征串起来理解整体。

换一个更生活化的类比:如果RNN是逐字逐句读文章并努力记住前文内容,那CNN就像是拿着一个放大镜在全篇快速移动,先识别出每个局部片段的特征(比如“这里有个尖峰”“这里有一段平台期”),再用更上层的卷积把这些特征组合成更大的模式(比如“连续三个尖峰后跟着一个下降趋势”)。这种思路在大量时间序列场景里不但可行,而且效率高,因为同一时刻所有卷积核可以并行扫描不同位置,GPU利用率比RNN高出不少。

1.2 因果卷积与感受野:CNN处理时序的关键

当然,直接用普通卷积处理时间序列会遇到一个尴尬的问题:普通卷积在计算当前位置的输出时,卷积核会同时覆盖当前位置前后的数据点。比如卷积核大小为3,计算第t个位置的输出时,它会用到第t-1、第t、第t+1三个点。这在图像分类里完全没问题,但在时间序列预测里是个灾难——你等于拿“未来数据”去预测“当前时刻”,这在很多业务场景里是不允许的,会导致严重的数据泄漏。

解决方法是“因果卷积”(Causal Convolution)。它的思路很简单:把卷积核的覆盖范围全部限制在当前时刻及之前。具体实现方式是对输入序列做左侧填充(left padding),保证输出序列和输入序列长度对齐,同时让卷积核只看到历史数据。用PyTorch实现时,只需把填充(padding)设置为(kernel_size - 1)并配合裁剪,或者直接用Conv1d的padding参数控制。以内核大小3为例,对输入左侧填充2个零,然后卷积,取有效部分,就能实现因果性。

但单靠因果卷积,模型的“视野”其实很浅。一层卷积核大小为3的Conv1d,每个输出点只能看到前3个输入点。如果序列中一个模式跨了20个时间步,浅层卷积根本捕捉不到。这时候就需要加层数或者用膨胀卷积(Dilated Convolution)扩大感受野。

膨胀卷积的原理可以这么理解:普通卷积核是“紧挨着”扫描,膨胀卷积让卷积核的采样点之间“拉开距离”,比如膨胀率为2时,核大小为3的卷积会采样第t-2、t-1、t三个位置(假设方向适当),相当于用更宽的视野看序列而不增加参数量。叠加多层膨胀率成倍增加的卷积层(比如1、2、4、8这样递增),模型的有效感受野呈指数增长。这一点很像你在看一幅远景图:先看细节,再往后退一步看更大范围,再退一步看到整条曲线的走势。这就是因果膨胀卷积在时间序列预测里特别实用的原因。

我在实际项目中用到的实现,通常是几层因果卷积配合膨胀率递增,再配合残差连接(Residual Connection)。残差连接的作用很直白:让梯度在网络反向传播时有一条“高速公路”,深层网络的训练稳定性提升非常多,这样能防止层数增加后出现梯度消失。

1.3 什么情况下优先选CNN而不是LSTM

我也不是一味鼓吹CNN替代LSTM,实际选型要看任务场景。我自己总结下来,CNN在下面几类情况里优势明显:

  • 追求训练效率和吞吐量。CNN可以大规模并行,GPU利用率高。LSTM因为串行特性,batch再大也得一步一步推。
  • 序列长度比较长。CNN用膨胀卷积可以灵活控制感受野,不像RNN那样容易出现长程依赖的梯度衰减问题。
  • 局部模式比全局状态更重要。很多传感器信号、金融短期走势、故障检测场景,本质是识别局部的形态特征,CNN天然擅长。
  • 需要轻量级部署。CNN模型结构简单、推理速度快,在边缘设备上跑也没有太大压力。

LSTM或者Transformer仍然有它们的优势:处理不定长序列、建模复杂长期依赖、处理离散token序列等场景,RNN家族和Transformer依然是很强的选择。我自己做时间序列预测时,经常先拿CNN快速验证可行性和收敛效果,如果效果满足需求,就直接用CNN;如果效果差,再上LSTM或Transformer做对比。用CNN做快速基线模型,是一个很实用的小技巧。

2. 数据端处理与模型结构拆解

2.1 滑动窗口切分与标准化

CNN不能直接吃“一条完整的时间序列”,它跟LSTM一样,需要把长序列切成一个个窗口。每个窗口包含一段历史观测值(比如前64个时间步),对应一个预测目标(比如下1个时间步的值)。切窗口的代码逻辑很直接:从序列开头滑动,每次往前走一步,取固定长度作为特征,再把目标值取出来。这就好比你看K线图,总喜欢回头多看几根K线的走势,然后判断下一根K线怎么走——只不过这里由滑动窗口代替你的眼睛。

窗口长度怎么选,没有绝对标准。我一般根据业务周期来定:如果数据有明显周期性(比如每天一个周期),窗口至少覆盖一个完整周期;如果做工业传感器预测,窗口要覆盖一次完整工况变化的时长;如果实在没有先验知识,就按经验值来:预测1步用64或128点,预测多步适当拉长。窗口太短会丢失关键上下文,窗口太长会引入大量无关噪声,增加训练时间和过拟合风险。

数据标准化这一步一定不能省。时间序列数据的量纲很随意,比如温度可能是个位数级别,压力可能是几千级别,如果直接喂进网络,数值大的特征在梯度计算里会占据绝对主导地位,数值小的特征很难被学习到。我通常的做法是对训练集的均值和标准差做归一化,然后复用同一个标准化参数处理验证集和测试集,而不是每一段数据单独归一化。这一点特别容易踩坑,若每段单独归一化,等于把各段数据的相对幅值关系抹掉了,模型在训练时看到的是“假统一”,一到真实预测就会崩。

2.2 模型结构:从基础版到改进版

如果你只是想把CNN跑起来,最简单的模型就是一个Conv1d接一个ReLU再接全连接层。但这样的模型表达能力很有限,基本只能抓到非常浅层的模式。我在项目里常用的基础结构是:两层因果膨胀卷积,每层后面接ReLU和Dropout,最后一层用全局平均池化或直接展平,接线性层输出预测值。

下面是一个可以“抄作业”的结构定义,基于PyTorch实现:

import torch import torch.nn as nn import torch.nn.functional as F class CausalConv1d(nn.Module): def __init__(self, in_channels, out_channels, kernel_size, dilation=1): super().__init__() # 左侧填充:(kernel_size - 1) * dilation,保证因果性 self.pad = (kernel_size - 1) * dilation self.conv = nn.Conv1d( in_channels, out_channels, kernel_size, dilation=dilation ) def forward(self, x): x = F.pad(x, (self.pad, 0)) # 只在左侧填充 return self.conv(x) class SimpleTCN(nn.Module): def __init__(self, in_channels=1, hidden_channels=32, num_classes=1, kernel_size=3): super().__init__() # 膨胀率从1开始逐层翻倍,感受野成指数增长 self.block1 = CausalConv1d(in_channels, hidden_channels, kernel_size, dilation=1) self.block2 = CausalConv1d(hidden_channels, hidden_channels, kernel_size, dilation=2) self.block3 = CausalConv1d(hidden_channels, hidden_channels, kernel_size, dilation=4) self.relu = nn.ReLU() self.dropout = nn.Dropout(0.2) # 展平后接全连接输出 self.fc = nn.Linear(hidden_channels * 32, num_classes) def forward(self, x): # 输入形状: (batch, channels, seq_len) x = self.dropout(self.relu(self.block1(x))) x = self.dropout(self.relu(self.block2(x))) x = self.dropout(self.relu(self.block3(x))) x = x.view(x.size(0), -1) return self.fc(x)

注意上面的全连接层输入维度写死了窗口长度为32,如果换窗口长度要同步调整。这个结构严谨地说是一个TCN的简化版。真正的TCN通常还会加残差连接和更深的层次,但对于中小规模数据集,三层膨胀卷积已经够用了。用这个结构跑常见时序数据,收敛速度和稳定性都比单层CNN好很多。

如果数据量比较大,想要更强表现力,可以给每个卷积块加一个残差连接,让输出等于输入变换和原始输入的叠加;也可以在卷积层之间加BatchNorm1d,让中间特征分布更稳定。加残差连接后的核心代码大致长这样:

class ResidualBlock(nn.Module): def __init__(self, channels, kernel_size, dilation): super().__init__() self.conv1 = CausalConv1d(channels, channels, kernel_size, dilation) self.conv2 = CausalConv1d(channels, channels, kernel_size, dilation) self.relu = nn.ReLU() def forward(self, x): out = self.relu(self.conv1(x)) out = self.conv2(out) return self.relu(x + out)

残差连接的好处不仅是训练稳定,它还能让网络在“什么都不做”和“学复杂特征”之间平滑过渡。堆叠多个残差块之后,模型即使层数很深,也没有想象中那么难训练。

2.3 损失函数、优化器与评估指标

时间序列预测本质是个回归任务,损失函数最常用的就是均方误差(MSE),PyTorch里对应nn.MSELoss()。这个损失函数对大误差非常敏感,因此模型会偏向把预测值往平均值上收敛来减小“离谱误差”,结果就是预测曲线往往比真实曲线平滑。如果你希望模型更关注误差的相对大小,可以考虑用均方对数误差(MSLE)或者平均绝对误差(MAE)。我自己习惯先用MSE训练,因为它的梯度特性让收敛更平稳;但如果发现预测曲线过于平滑,再切换损失函数或增加对误差的约束。

优化器我默认用Adam,学习率起步一般设在1e-3,batch size在64到256之间。训练过程中我通常会做一个简单的手动学习率衰减:每跑若干轮后除以2。Adam虽然自适应调节学习率,但训练后期损失降到平台期时,手动降学习率依然能带来肉眼可见的收益。

评估指标不能只看训练损失,要额外算验证集或测试集上的MAE、RMSE和MAPE(平均绝对百分比误差)。这三者的侧重点完全不同:

  • MAE反映平均绝对偏离程度,最直观;
  • RMSE因为平方放大了大误差的影响,能反映“最大偏差是否离谱”;
  • MAPE给出的是偏离百分比,方便与业务方解释。

如果预测目标数值跨度很大,MAPE很容易被小的真实值带偏(比如真实值接近0时,MAPE会爆炸),所以它适合目标值稳定在某一量级的场景。

3. 完整实操流程(PyTorch实现)

3.1 环境准备与虚拟数据生成

先说明一下版本:PyTorch 2.x都可以跑,没有额外依赖。我习惯先把环境搭好,然后生成一份可复现的实验数据。这里我用一个正弦波叠加趋势和噪声的合成序列来演示,原因有二:一是数据自己可控,便于观察模型是不是学到了真实规律;二是代码可以直接复现,不依赖任何外部数据集。

环境方面,装好torch、numpy、matplotlib就够用。序列生成代码大致如下:

import numpy as np import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader import matplotlib.pyplot as plt np.random.seed(42) torch.manual_seed(42) # 生成合成时间序列:正弦 + 趋势 + 噪声 seq_len = 2000 t = np.linspace(0, 40, seq_len) signal = np.sin(t) + 0.05 * t + 0.1 * np.random.randn(seq_len) signal = signal.astype(np.float32)

这份数据在视觉上能看到一条带周期性和缓慢上升趋势的曲线,加入一定噪声后更接近真实传感器信号。用这份数据训练,如果模型能预测出大致的周期和趋势走向,说明基本逻辑没问题。真实项目里,只需要把signal换成你自己读取的序列就行。

窗口化处理逻辑如下:

def create_sequences(data, window_size=32, pred_len=1): X, y = [], [] for i in range(len(data) - window_size - pred_len + 1): X.append(data[i: i + window_size]) y.append(data[i + window_size: i + window_size + pred_len]) return np.array(X, dtype=np.float32), np.array(y, dtype=np.float32) window_size = 32 X, y = create_sequences(signal, window_size, 1) # 按7:1:2划分训练/验证/测试集,注意不能打乱顺序 train_size = int(len(X) * 0.7) val_size = int(len(X) * 0.1) X_train, y_train = X[:train_size], y[:train_size] X_val, y_val = X[train_size: train_size + val_size], y[train_size: train_size + val_size] X_test, y_test = X[train_size + val_size:], y[train_size + val_size:]

这里有一个很关键的原则:时间序列数据划分时,不能用随机打乱的方式切分,必须按时间顺序依次划分。因为一旦打乱,训练集里会出现“未来”数据,“验证集”的分布和真实预测场景就完全不一致了。我见过有人拿图像分类的思路直接train_test_split(random_state=42)处理时间序列,结果验证集效果极其好看,上线后完全崩盘,原因就是数据泄漏。

3.2 数据集类与数据加载器

PyTorch里自定义数据集需要继承torch.utils.data.Dataset并实现__len__和__getitem__。这里有一个细节:因为我们用的是归一化后的数据,且训练集、验证集和测试集都复用了同一组标准化参数,所以在数据集类里不需要再做归一化。

class TimeSeriesDataset(Dataset): def __init__(self, X, y): self.X = torch.from_numpy(X).unsqueeze(1) # (n_samples, 1, window_size) self.y = torch.from_numpy(y) def __len__(self): return len(self.X) def __getitem__(self, idx): return self.X[idx], self.y[idx]

在训练之前,需要用训练集的统计量做标准化:

train_mean = X_train.mean() train_std = X_train.std() X_train = (X_train - train_mean) / train_std X_val = (X_val - train_mean) / train_std X_test = (X_test - train_mean) / train_std

注意这里对三个集合都用了同一个train_mean和train_std,绝对不能分别对每个集合调用自己的均值和标准差。这样做会让模型在训练时看到的输入分布与推理时不一致。我早期吃过这个亏,后来把标准化写成了一个可复用的类,确保预测时能调用训练阶段保存的均值和标准差。

数据加载器方面,训练集和验证集可以设置不同的batch size,但注意测试集预测时一般不建议再按batch切分。常用的写法:

batch_size = 128 train_loader = DataLoader(TimeSeriesDataset(X_train, y_train), batch_size=batch_size, shuffle=True) val_loader = DataLoader(TimeSeriesDataset(X_val, y_val), batch_size=batch_size, shuffle=False) test_loader = DataLoader(TimeSeriesDataset(X_test, y_test), batch_size=batch_size, shuffle=False)

shuffle=True只用于训练集。验证集和测试集不要打乱顺序,否则你后面想按时间顺序画预测对比图时,数据完全对不上号。

3.3 模型训练与损失收敛观察

模型实例化、损失函数和优化器的配置如下:

model = SimpleTCN(in_channels=1, hidden_channels=32, num_classes=1, kernel_size=3) criterion = nn.MSELoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

训练循环我习惯记录每一轮的训练损失和验证损失,验证集参照系主要用于判断有没有过拟合。一个标准的训练循环示例:

epochs = 80 train_losses = [] val_losses = [] for epoch in range(epochs): model.train() epoch_loss = 0.0 for X_batch, y_batch in train_loader: optimizer.zero_grad() pred = model(X_batch) loss = criterion(pred.squeeze(), y_batch.squeeze()) loss.backward() optimizer.step() epoch_loss += loss.item() * X_batch.size(0) train_losses.append(epoch_loss / len(X_train)) model.eval() val_loss = 0.0 with torch.no_grad(): for X_batch, y_batch in val_loader: pred = model(X_batch) loss = criterion(pred.squeeze(), y_batch.squeeze()) val_loss += loss.item() * X_batch.size(0) val_losses.append(val_loss / len(X_val)) if (epoch + 1) % 20 == 0: print(f'Epoch {epoch+1}/{epochs}, Train Loss: {train_losses[-1]:.6f}, Val Loss: {val_losses[-1]:.6f}')

这里有个小细节:optimizer.zero_grad()必须在每个batch计算前调用,否则梯度会累加,导致更新方向混乱。loss.backward()之后如果发现梯度里有nan,多半是学习率过大或输入数据有异常值,可以先用一个小学习率跑通流程再逐步调大。

训练完成后,我习惯打印训练损失和验证损失的曲线。如果验证损失明显高于训练损失且差值越来越大,说明过拟合,可以增加Dropout系数、减小模型容量或者增加数据量。如果两个损失都居高不下,多半是模型容量不足、窗口太小或者数据本身标准化做错了。

3.4 预测与逆标准化:这一步不能省

模型输出的预测值是基于标准化后的数据计算出来的,直接拿去跟原始数据对比会完全对不上。所以预测之后要做逆标准化:

model.eval() with torch.no_grad(): test_pred = model(torch.from_numpy(X_test).unsqueeze(1)) test_pred = test_pred.squeeze().numpy() test_true = y_test.squeeze() # 逆标准化 test_pred = test_pred * train_std + train_mean test_true = test_true * train_std + train_mean

只有逆标准化之后,得到的预测值和真实值才在同一个量纲下,画图对比才有意义。可视化是判断模型好坏最直观的手段之一。把测试集最后若干条真实曲线和预测曲线画在一起,重点看三点:趋势是否跟随得上,周期是否对齐,突变点是否被平滑掉了。CNN模型的一个通病是突变点预测会显得迟钝,因为卷积本质上做了局部加权平均,天然会平滑一些细碎的突变,这不能算模型失败,要看整体预测误差是否在可接受范围内。

一个更完善的评估是计算测试集上的MAE和RMSE:

from sklearn.metrics import mean_absolute_error, mean_squared_error mae = mean_absolute_error(test_true, test_pred) rmse = np.sqrt(mean_squared_error(test_true, test_pred)) print(f'MAE: {mae:.4f}, RMSE: {rmse:.4f}')

我见过很多初学者在评估时只看loss曲线,画一条训练曲线自嗨,不去算实际误差或画预测对比图。其实很多时候训练loss下降得很好看,但真实预测因为数据泄漏或标准化问题一塌糊涂。一份好的时间序列预测实验报告,一定要包含“真实值 vs 预测值”的对比可视化。

3.5 多步预测怎么扩展

上面的例子是单步预测,即用过去32个点预测下1个点。如果业务需求是预测未来5个点、10个点甚至更长时间,单步模型就不能直接胜任,需要改成多步预测。多步预测常见做法有两种:递推多步预测和直接多步预测。

递推多步预测的思路:先用模型预测第1步,把这个预测值当作已知输入拼到历史序列末尾,再预测第2步,依次类推。这种方法实现简单,模型不用改,但缺点是误差会逐步累积,预测步数越长,偏差越大,尤其在有周期性波动的数据上,后面几步经常出现相位漂移。

直接多步预测的思路:把输出层的num_classes改成要预测的步数,一次输出未来若干个点的值。比如num_classes=5,模型直接输出未来5步。这样做模型需要学习“输出多个目标”的任务,跟业务目标更对齐,误差不会像递推法那样逐级累积。缺点是如果预测步数太长,模型需要更大的容量。我的做法是:5步以内直接多步预测,超过10步可以采用“长窗口直接预测”或干脆换LSTM/Transformer结构。

4. 常见问题与排查技巧实录

4.1 训练集效果很好但预测集崩盘

这个现象我在交流群里看到过好多次,几乎每个人都会经历一次,我也不例外。通常原因是数据泄漏,泄漏的途径主要有三条:

第一条,标准化用了全量数据的均值和标准差。比如你先把整条序列的均值和标准差算出来,再切训练集和测试集,这等于模型在训练时“偷看”了测试集的统计信息。解决办法是只用训练集计算标准化参数,再应用到其他集合。

第二条,随机打乱数据划分。时间序列天然有前后依赖关系,一旦打乱,训练集和测试集的数据分布变成“混洗状态”,验证集里可能包含训练集的未来数据。务必保持时间顺序划分。

第三条,滑动窗口重叠跨度太大导致信息泄露到验证集。严格来说,训练集最后一个窗口和验证集第一个窗口在时间上是相邻的,这种“相邻”不算严重泄漏,因为预测目标不同。但如果窗口长度本身就覆盖了整个序列的一部分,而且验证集紧挨着训练集,那前几个验证样本会携带训练集末尾的历史信息,这属于轻微的信息泄漏,可以接受。更严格的做法是把训练集和验证集之间隔开一小段数据作为缓冲,比如在切分时从中间丢掉一些样本。

排查思路很简单:先检查标准化流程,再检查数据切分方式,最后打印几个训练样本和验证样本来确认时间顺序。把这三处都过一遍,预测集崩盘的问题基本能定位出来。

4.2 模型怎么调参都学不到周期规律

如果训练损失下降很慢,或者预测结果看起来就是一条“平线”,大概率是感受野不够大。感受野说白了就是卷积网络能看到多长的历史范围。如果窗口是32,但模型每层的感受野加起来只有10,那模型对这32个点里后面10个点之前的模式完全“看不见”,周期信息自然学不到。

判断感受野的方法不复杂:每层因果膨胀卷积的感受野是(kernel_size - 1) * dilation + 1,多层堆叠时逐层累加。一个卷积核大小为3、膨胀率分别为1、2、4的三层结构,感受野是(3-1)*1 + 1 + (3-1)*2 + (3-1)*4,大致15个点左右。如果你的序列周期是50个点,15个点的感受野根本覆盖不了一个周期。解决方法是增加膨胀率(比如1、2、4、8)或者加深层数,并相应调整窗口长度,保证感受野至少覆盖一个完整周期。

调参时我还发现一个规律:窗口长度应该略大于感受野,而不是让感受野等于窗口长度。因为模型需要一些“冗余”信息来提升鲁棒性,如果窗口长度刚好卡在感受野上,边缘位置的卷积计算信息利用率很低。我的经验是让窗口长度是感受野的1.5倍到2倍左右比较合适。

4.3 训练过程不收敛或损失震荡

训练不收敛通常有几个直接原因。第一个是学习率过大。我见过有人把Adam的学习率直接设成1e-2,结果损失曲线像心电图一样剧烈震荡。Adam的默认学习率1e-3对大多数回归任务都合适,如果发现震荡,先把学习率降到3e-4或1e-4再试。第二个是输入数据包含异常值或NaN,卷积输入一旦出现NaN,梯度传播会直接摧毁全部参数。训练前要做一次数据清洗,把异常点处理掉。第三个是损失函数与输出层不匹配,比如输出层用了Sigmoid激活,但预测目标不在0到1范围内,这样梯度更新方向就错乱了。回归任务输出层一般不加激活函数,直接输出线性值。

另外,Dropout在训练和验证之间的行为差异也容易让人误判。训练阶段Dropout会随机丢弃部分神经元,所以训练损失会比验证损失略高,这是正常的;如果验证损失比训练损失还低,先确认模型是否真的在训练阶段开启了Dropout,再确认验证阶段是否用了torch.no_grad()。

4.4 CNN与LSTM到底哪个更稳

我在同一个合成数据集上对比过CNN和LSTM,结论很有意思:如果序列规律简单、局部模式明确,CNN收敛更快、测试误差也更低;如果序列的规律依赖长距离上下文,比如“某个模式只会在几百步前出现过一次,但对当前预测起决定作用”,LSTM或者Transformer会明显更强。时间序列预测不是“一招鲜吃遍天”,CNN适合做快速建模和局部特征提取,RNN家族适合做复杂状态依赖建模。

我个人的工作流是:先用CNN跑一个快速基线,如果性能达标就直接交付;如果性能不行,再切换到LSTM或Transformer做对比。这样既能在大多数场景里省下大量训练时间,又不会错过更强的模型带来的提升空间。这种做法的工程价值相当高,尤其是面对数据集动辄几十万条样本的生产环境,训练速度就是成本。

4.5 一个“偷分”小技巧:预测后做指数平滑

如果在测试集上看一下预测曲线,你会发现CNN模型虽然总体趋势对,但在细节上总会有锯齿状的抖动。这是因为模型在逐点回归时,局部噪声也被当作信号学了一部分。一个简单的后处理办法是:对预测结果做指数平滑,让曲线更平滑。具体做法是用一个衰减系数alpha,让当前平滑值等于上一时刻平滑值和当前原始预测值的加权平均:

def exponential_smooth(pred, alpha=0.3): smoothed = [pred[0]] for p in pred[1:]: smoothed.append(alpha * p + (1 - alpha) * smoothed[-1]) return np.array(smoothed)

我试过很多次,这个操作对MAE的影响不大,但视觉上曲线真实度提升明显。特别适合需要把预测结果拿给业务方看的场景。不过要注意,如果业务需要捕捉突变点,平滑会把这些点给抹掉,这种场景下就别用后处理平滑了。

最后分享一点个人感受:用CNN做时间序列预测,最大的门槛其实不在模型结构上,而是在数据处理是否规范、感受野设计是否合理。只要把因果填充、数据标准化、时间顺序划分这几点做好,一个简单的三层膨胀卷积结构,已经能解决不少实际业务问题。后面想继续提升精度,可以再引入注意力机制、多尺度卷积,甚至把CNN和Transformer做混合结构——这些都是在这个基础上延伸出去的思路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询