简介:面向时间序列分析与深度学习初学者的1D-CNN入门代码包,聚焦一维卷积神经网络在序列数据上的建模流程,覆盖数据预处理、网络构建、训练评估与预测调用等完整环节。资源共3个Python脚本,整个压缩包仅3KB,轻量易读,适合快速定位到模型结构或训练配置做修改实验;已有3357人学习下载。三个脚本分工清晰:模型脚本完成卷积层、池化层、全连接层等结构搭建;训练脚本负责数据标准化与划分、优化器与损失函数配置、迭代训练与验证;预测脚本可直接加载训练好的模型,对新时间序列输出分类或回归结果。配合资源描述,可对照理解卷积核沿时间轴滑动提取局部特征、ReLU引入非线性、池化下采样降低维度等关键概念,并迁移到股票价格、语音信号或传感器数据等场景中。对于正在学习CNN时间序列建模、需要简洁可运行示例的读者,这份代码提供了从零到预测的完整参考,也方便扩展到更多1D-CNN应用场景。
1. 1D-CNN 做时间序列:不靠 LSTM 也能把预测和分类跑通的轻量方案
一批 GNSS 站点的高程时间序列、一条工业设备的振动波形、一组传感器连续采集的读数——它们数值形态完全不同,但在建模时都能被塞进同一个三维张量:(batch, time_steps, features)。这就是 1D-CNN 和时间序列能反复组合在一起的根本原因:时间序列的局部结构(趋势拐点、噪声形态、周期片断)正是卷积核擅长抓取的对象。这篇文章面向两类人:一类被 LSTM 训练速度折磨,想找更轻的替代方案;另一类手上有序列数据,但不确定该从哪一步开始建模。我会从数据构建讲到调参,再给一组踩过的坑,力求让这套流程可以直接套用到你自己的数据上。
2. 把原始序列变成模型能吃的输入:滑窗、归一化与数据分割
在动手搭 PyTorch 或 TensorFlow 模型之前,有个更基础的问题容易被忽略:1D-CNN 吃的不是“一列数”,而是“一段一段的窗口”。所以第一步永远是滑窗采样,把时间序列转换成监督学习的样本对 (X, y)。这也是网上很多时间序列预测教程最容易略过的地方——直接拿原始序列喂模型,维度报错后才回来补课。
2.1 单变量还是多变量:先把输入张量的形状钉死
1D-CNN 的输入在 PyTorch 里是 (batch, features, time_steps),在 TensorFlow/Keras 里是 (batch, time_steps, features)。无论哪种框架,都要先回答一个问题:你手里的是单变量序列还是多变量序列?
单变量时间序列预测模型只有一列数值,features=1;多变量则有多个通道,比如 GNSS 时间序列预测里同时用高程、平面坐标和环境气温,features=n。很多新手会把多变量的不同列当成不同样本堆到 batch 维,结果模型把同一时刻的不同通道当成独立样本,彻底失去时间对齐关系。这一点建议在数据处理阶段就写进注释,省得回看脚本时还要重新推理。
def sliding_window(series, lookback, horizon, step=1): X, y = [], [] for i in range(0, len(series) - lookback - horizon + 1, step): X.append(series[i:i + lookback]) y.append(series[i + lookback:i + lookback + horizon]) return np.array(X), np.array(y)lookback 是窗口长度,也就是模型回头看的步数;horizon 是要预测的未来步数;step 控制窗口移动的步幅。step=1 时样本量最大,但相邻样本重叠非常严重,后续训练要禁止 shuffle;如果数据量大,step 可以设为 2 或 5 来降低采样密度。lookback 是我最常调的第一个超参数,经验上从 10、20、50 这几个刻度试起。horizon=1 时做的是单步预测,每个窗口对应一个标量;horizon 大于 1 时 y 就是多步输出,长度等于 horizon。
多变量版本要稍微改一下:
def sliding_window_multivariate(data, target, lookback, horizon, step=1): X, y = [], [] for i in range(0, len(data) - lookback - horizon + 1, step): X.append(data[i:i + lookback]) # (lookback, n_features) y.append(target[i + lookback:i + lookback + horizon]) return np.array(X), np.array(y)data 是 (seq_len, n_features) 的二维数组,target 可以单独指定某一列或另一个序列作为预测目标。处理之后 X 的形状是 (num_samples, lookback, n_features),直接传给 Keras 的 Input 层;PyTorch 则还需要 permute 成 (num_samples, n_features, lookback)。如果数据里既有趋势又有明显的周期性波动,可以先做差分或季节分解再进模型。1D-CNN 不是不能拟合趋势,而是把趋势和波动一起学会让卷积核的权重分配变得很别扭,尤其是预测步数较大时。
2.2 归一化策略:先 fit 训练集,再 transform 测试集
归一化在时间序列建模里比在图像里更重要,因为卷积网络对输入尺度非常敏感,ReLU 的输出范围直接受输入影响。常见做法是 StandardScaler 或者 MinMaxScaler,区别在于前者假设分布大致对称,后者把数据压到 [0,1] 区间。我在处理 GNSS 坐标序列时更倾向于 StandardScaler,因为坐标序列本身会有线性趋势,MinMaxScaler 会把个别离群点放大;而传感器振动信号这类平稳信号用 MinMaxScaler 更稳定。这里给出正确写法:
from sklearn.preprocessing import StandardScaler scaler = StandardScaler() train_flat = train_X.reshape(-1, n_features) train_X_std = scaler.fit_transform(train_flat).reshape(train_X.shape) val_flat = val_X.reshape(-1, n_features) val_X_std = scaler.transform(val_flat).reshape(val_X.shape)scaler 只能 fit 在训练集上,测试集和未来预测段都用同一套均值和方差 transform;否则测试集的统计量会通过 scaler 泄漏进训练过程,让验证指标虚高。第二点,如果目标 y 和输入 X 的量纲差异很大,比如 X 是位移、y 是速度,y 也要单独做一次归一化,并在反算预测值时用对应的 scaler.inverse_transform 还原。数据分割这里有个常见的错误认知:时间序列不能像图像那样直接 random split,因为滑窗之后相邻样本共享大量时间点,随机划分会让训练集和验证集出现在同一段原始序列上,等于模型在训练时见过验证集。正确做法是按时间顺序切分:前 70% 训练、后 15% 验证、最后 15% 作为待预测的“未来”。
2.3 数据量决定加载方式:全量数组还是 DataLoader
数据量在几万样本以内时,全量数组直接喂给模型最简单,不用额外实现生成器。窗口数据本身不大,一个中等规模的数据集完整放进去也只要几百 MB 内存。超过这个量级,或者你准备做在线增量训练,再用 PyTorch 的 DataLoader:
import torch from torch.utils.data import TensorDataset, DataLoader train_dataset = TensorDataset( torch.from_numpy(train_X_std).float(), torch.from_numpy(train_y_std).float() ) train_loader = DataLoader(train_dataset, batch_size=256, shuffle=False)shuffle=False 这一行是时间序列训练和图像训练最大的区别。图像任务里 shuffle 能打破样本顺序、加速收敛;时间序列里 shuffle 会把未来窗口的信息泄漏给过去的窗口,训练集 loss 照样降得很漂亮,但真实滚动预测时误差会迅速累积。这条我踩过不止一次,后面避坑章节会专门展开。
3. 搭 1D-CNN 主体:Conv1D、池化与感受野的搭配逻辑
很多第一次接触 1D-CNN 的人会以为它只是在二维 CNN 基础上把卷积核压扁,实际用起来才知道,Conv1D 在时间序列上的行为更像是“一组沿着时间轴滑动的局部滤波器”。它和全连接层的本质区别在于:权重是跨时间位置共享的,所以模型学到的是“某种局部模式”,而不是“第 5 个时间点的数值”。这个特性让 1D-CNN 在时间序列上天然具备两个优势:平移不变性和参数量小。
3.1 Conv1D 如何作用于时间序列:维度变换和感受野
PyTorch 的 Conv1d 输入是 (batch, in_channels, seq_len),而我们在第 2 章得到的数据形状是 (batch, lookback, n_features),所以 forward 的第一步通常是 permute(0, 2, 1)。这一步把特征维度放到通道位,把时间维度放到最后,很多初学者的第一个 runtime error 就出在这里。理解和记住这个维度转换,比背代码有用,因为换到 Keras 时又要换成完全相反的 (batch, lookback, features) 写法。
感受野这个概念对超参选择很有用。一个卷积核大小为 k 的 Conv1d,单层感受野就是 k;两层堆叠之后,感受野变成 1 + 2*(k-1),三层则继续累加。也就是说 kernel_size=5 的三层卷积可以看到原始序列约 9 个点,但参数只相当于一个 kernel 为 9 的单层卷积的三倍左右,非线性表达还更强。我曾经在一个工业传感器抖动检测任务里把 kernel_size 从 3 调到 7,模型对半周期波形的敏感度明显提升,但调到 9 以后训练开始震荡。直观解释是:kernel 过大时,卷积核覆盖一个完整周期再加一段噪声,反而学不到稳定的局部模式。
如果想在不加深网络的情况下扩大感受野,可以把 stride 设为 2 逐层下采样,也可以引入 dilation。这一点对处理 lookback 很长的序列很关键,等到了第 6 章我会给出具体替换方式。还有一个容易忽略的细节:为什么用 1D-CNN 而不是把序列拉平后接全连接?全连接层会把每个时间位置当成独立特征,序列的顺序关系只能靠网络自己硬记;卷积层则通过权值共享强制模型在时间维度上复用同一组模式,参数量小一个量级,泛化能力反而更好。
3.2 一个基线网络:三层 Conv1D 加全局平均池化
下面这个结构是过去一年我反复使用的时间序列基线模型,用于回归和分类都能快速跑通。它足够简单,没有魔改,适合作为对照基准,也适合作为第一版方案直接上线。
import torch.nn as nn class BaseTimeCNN(nn.Module): def __init__(self, n_features, lookback, horizon=1, hid1=64, hid2=32, k=5, dropout=0.2): super().__init__() self.conv_block = nn.Sequential( nn.Conv1d(n_features, hid1, k, padding='same'), nn.BatchNorm1d(hid1), nn.ReLU(), nn.Dropout(dropout), nn.Conv1d(hid1, hid2, k, padding='same'), nn.BatchNorm1d(hid2), nn.ReLU(), nn.Dropout(dropout), ) self.pool = nn.AdaptiveAvgPool1d(1) self.head = nn.Linear(hid2, horizon) def forward(self, x): x = x.permute(0, 2, 1) # (batch, lookback, n_features) -> (batch, n_features, lookback) h = self.conv_block(x) # (batch, hid2, lookback) h = self.pool(h).squeeze(-1) # (batch, hid2) return self.head(h)几个参数需要解释。padding='same' 让卷积输出与输入时间长度一致,避免了 pool 之后时间维度对不上;BatchNorm1d 在序列数据上依然好使,它按通道做标准化,可以让深层网络训练更稳;AdaptiveAvgPool1d(1) 把时间维度压成 1,等效于全局平均池化。有些实现会写成 Flatten 再接全连接,在序列较短时也可以,但参数会多出几十倍,我倾向于用全局池化,既省参数又能缓解过拟合。horizon 的输出由最后一个 Linear 层决定。做单步预测时 horizon=1,做多步直接预测时 horizon=n 就是 n 个输出神经元,训练时要求标签 y 的 shape 与输出一致。
如果你用的是 Keras,等价的写法是把 Conv1D 放在第一层,输入形状写成 (lookback, n_features),不需要手动 permute。PyTorch 的 (batch, features, lookback) 和 Keras 的 (batch, lookback, features) 只是数据布局差异,模型逻辑完全一样,不要被两边示例代码里不同的维度顺序搞晕。
提示:如果你的 lookback 只有十几个点,我会建议把 kernel_size 压缩到 3;如果序列有几百个点,再考虑 5 或 7。卷积核大小应该随输入长度等比缩放,而不是固定不变。
3.3 为什么在这个任务里不用 LSTM:1D-CNN 的定位与边界
提到时间序列预测,很多人的第一反应还是 LSTM。从结论上说,单变量时间序列预测模型在样本量不大(几千到几万)、序列长度中等的场景下,1D-CNN 通常能以更短训练时间拿到和 LSTM 接近甚至更好的效果。原因在于:LSTM 的优势是长程依赖,但代价是逐时间步串行计算,训练速度和收敛稳定性都比 CNN 差;而现实中的 GNSS 时间序列预测、设备故障预警这类任务,起决定作用的往往是最近几十个点内的局部形态。
当序列长度超过几百步,并且确实存在跨长距离的依赖时,纯 1D-CNN 就会吃力。这时我不会硬上 LSTM,而是先用膨胀卷积把感受野拉大,再不行才考虑 CNN-LSTM 串行结构:CNN 负责提取局部特征,LSTM 负责对压缩后的特征序列建模时序关系。不过这条路线复杂度更高,调试时间翻倍,建议作为第二步而不是第一版方案。先让 1D-CNN 基线跑通、拿到可对比的数字,再决定是否值得增加复杂度。
4. 训练与调参:损失、学习率、早停的落地配置
网络结构确定之后,训练配置直接决定模型能不能收敛、泛化好不好。这一章给出一套完整的训练流程,包括损失函数选择、训练循环、学习率调整和早停设置。所有代码都可以直接复制修改,不依赖复杂的训练库,也没有框架封装。
4.1 回归与分类任务的损失函数和评估指标
先分清任务类型。做时间序列预测时,绝大多数情况是回归,用 MSE 当主损失;如果目标是预测第二天的上涨/下跌、设备是否故障、波形类别,那是分类,用交叉熵。一个容易忽略的点是:MSE 对离群值敏感,如果你的数据里有粗差,一个异常样本会把整个模型的梯度带偏。GNSS 时间序列预测里经常出现接收机周跳导致的跳变,直接用 MSE 会让模型学出“预测值往极端方向偏”的倾向。这种情况可以换 Huber Loss,它在残差较小时退化为 MSE,残差大时退化为 MAE,对粗差更稳。
评估指标和损失要分开选。回归任务我习惯同时看 MAE 和 RMSE:MAE 反映平均绝对偏差,量纲和原始数据一致,便于向业务方解释;RMSE 放大较大误差,用于判断模型是否在某些时段系统性崩溃。分类任务则要盯着 F1 而不是 accuracy,因为时间序列异常检测的数据集几乎必然正负样本不均衡,accuracy 会给你一个虚高但毫无意义的数字。
4.2 完整训练函数:早停、学习率衰减和模型保存
下面的代码是一个可以直接跑的训练函数。它没有做分布式或混合精度,但包含了工程中最必须的三个机制:验证集监控、ReduceLROnPlateau 学习率衰减、以及基于验证损失的早停。
import torch import torch.nn as nn import numpy as np def train_cnn(model, train_loader, val_loader, epochs=100, lr=1e-3, patience=10): optimizer = torch.optim.Adam(model.parameters(), lr=lr) criterion = nn.MSELoss() scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', factor=0.5, patience=5 ) best_loss = float('inf') wait = 0 for epoch in range(epochs): model.train() train_loss = [] for xb, yb in train_loader: optimizer.zero_grad() out = model(xb) loss = criterion(out, yb) loss.backward() optimizer.step() train_loss.append(loss.item()) model.eval() val_loss = [] with torch.no_grad(): for xb, yb in val_loader: out = model(xb) val_loss.append(criterion(out, yb).item()) avg_val = float(np.mean(val_loss)) scheduler.step(avg_val) if avg_val < best_loss: best_loss = avg_val torch.save(model.state_dict(), 'best_model.pth') wait = 0 else: wait += 1 if wait >= patience: print(f'early stop at epoch {epoch}') break if (epoch + 1) % 10 == 0: print(f'epoch {epoch+1} | train {np.mean(train_loss):.5f} | val {avg_val:.5f}') return best_losslr=1e-3 是 Adam 在大部分序列回归任务上的安全起点,如果训练 loss 震荡,可以先降到 3e-4。ReduceLROnPlateau 的 patience=5 代表连续 5 轮验证损失不下降就把学习率减半;早停的 patience 我习惯给 10,是学习率调度的两倍,避免学习率还没降到位就提前停掉。best_model.pth 只保存验证集最优的权重,而不是最后一轮,这算是最朴素的后悔药——训练最后几轮如果过拟合了,直接从最优 checkpoint 恢复就行。训练过程里如果发现 val_loss 比 train_loss 低很多,多半不是模型好,而是数据泄漏,回到第 2 章检查切分顺序。
4.3 一组可以直接套用的参数区间
参数配置在不同任务之间差异会很大,但相同任务类型里有明显的聚集区间。下面是我在多个序列预测项目里整理出来的默认值,基本不会跑出大问题。
| 参数 | 常用区间 | 我的默认值 | 说明 |
|---|---|---|---|
| kernel_size | 3 ~ 9 | 5 | 小于 lookback 的 1/5 |
| 第一层卷积核数 | 32 ~ 128 | 64 | 太少欠拟合,太多过拟合 |
| 第二层卷积核数 | 16 ~ 64 | 32 | 逐层递减 |
| dropout | 0 ~ 0.3 | 0.2 | 数据超过 5 万可考虑关闭 |
| batch_size | 32 ~ 256 | 256 | 序列越长 batch 越小 |
| 初始学习率 | 1e-4 ~ 3e-3 | 1e-3 | Adam 下按 loss 震荡微调 |
| lookback | 10 ~ 100 | 30 | 按业务周期估计 |
这几组参数之间是关联的,不能单独抽一个出来照抄。比如 lookback 调大之后,卷积层数和卷积核数量也需要跟着加,否则感受野覆盖不了整个窗口。batch_size 和学习率也存在耦合,batch 越大,我越倾向于用更大的学习率以匹配梯度噪声的变化。调参的时候我会用上面这张表做起点,每次只动一个变量,记录验证集指标,而不是同时改三四个参数,否则出了问题根本不知道是哪一步引起的。学习率这块尤其玄学,同一个网络换一份数据,最优学习率可能差一个数量级,所以网格扫描比凭感觉拍脑袋靠谱得多。
5. 避坑指南:时间序列用 1D-CNN 最容易翻车的 5 个细节
网络搭好了,函数也能跑通,真正决定这个方案能不能上线的往往是训练流水线里那些一眼看不出来的细节。下面 5 条坑是按出现频率排的,每一条都按现象、原因、解决来写,方便对号入座。
5.1 shuffle=True 让模型把未来信息当成了答案
现象:训练 loss 下降速度惊人,验证 loss 也很好,但把模型部署到线上做滚动预测时,误差逐日增大,甚至出现预测值整体滞后一拍的怪异现象。
原因:滑窗之后相邻样本在时间上高度重叠。shuffle=True 会把未来的样本随机插入到过去样本之间,模型实际看到的是“用未来窗口预测历史”,学到的规律在真实推理时根本不存在。这是时间序列场景最容易混淆的一点,因为图像任务里 shuffle 是默认操作。
解决:DataLoader 里固定 shuffle=False,而且从窗口构建开始就不要随机化。如果你用的是 Keras 的 model.fit,同样不要依赖它自带的随机分割,手动按时间切片之后传入 validation_data 参数。
5.2 kernel_size 比 lookback 还长
现象:网络能建起来,但训练 loss 不降反升,或者输出值整体异常偏大,预测曲线几乎是一条直线。
原因:卷积核的长度超过输入序列时,卷积操作在滑动过程中只能覆盖极少数的位置,等价于把全连接层强行塞进卷积层。kernel 越大权重越多,数据量不变时过拟合是必然的。窗口本身才 10 个点,却拿一个 kernel_size=7 的滤波器去扫,能提取到的完整模式很少。
解决:把 kernel_size 控制在 lookback 的 1/10 到 1/5。lookback 为 20 时用 3 或 5,lookback 为 100 时再考虑 7 到 9。可以用一个简单断言在训练前检查:
assert kernel_size <= lookback // 5, \ f'kernel_size={kernel_size} 超过 lookback/5={lookback // 5},请先缩小卷积核或增大窗口'这个检查尤其适合在写通用训练脚本时使用,能挡住一批因为超参组合错误而烧掉的时间。
5.3 归一化时不小心用了全量数据的统计量
现象:验证集上指标很好看,但把模型接到真实新数据上后,预测分布整体偏移,偏差方向还不固定,时高时低。
原因:标准化时如果先对整条序列做 fit 再切分,训练窗口和验证窗口的均值和方差都已经互相渗透,验证集不再是严格的未来数据。这属于典型的数据泄漏,比 shuffle 更难发现,因为训练过程完全正常,loss 曲线也符合预期。
解决:严格遵循“先按时间切分,再在训练段上 fit scaler,再 transform 训练段、验证段和未来预测段”。代码上把 fit_transform 和 transform 分开写,永远不要在测试段上调用 fit_transform。模型上线时也要保存好这个 scaler,新的输入数据必须用同一个对象做 transform,而不是重新算均值和方差。
5.4 多步预测用递归策略,误差滚雪球
现象:horizon 设为 10 或 30 后,预测曲线的开头几步还正常,越往后越偏离真实值,最后一段直接平掉或发散。
原因:递归预测把第 t+1 步的预测值当成第 t+2 步的输入,误差会随步数指数积累。模型训练时的输入全部来自真实序列,推理时一旦输入分布发生偏移,后面的预测就完全失效。这个现象在异常检测场景里尤其危险,因为模型可能会把自身的累积误差当成一个“异常”报警。
解决:horizon 不大(比如 30 步以内)优先用直接预测,让模型一次性输出多步结果,损失直接作用在整段预测序列上。做法很直接:把 head 输出维度改成 horizon,标签 y 的维度同步改成 (样本数, horizon)。如果 horizon 大到上百步,再考虑 seq2seq 结构,但复杂度会显著上升,不建议第一版就上。
5.5 小数据集上 CNN 疯狂过拟合
现象:训练 loss 一路降到接近 0,验证 loss 在前几个 epoch 下降后立刻反弹。换更小的网络也只是延缓过拟合,没有本质改善,dropout 加得再猛也无济于事。
原因:时间序列样本天然存在大量冗余,滑窗后看着有几万样本,有效信息量可能只相当于几十段独立序列。CNN 参数量比全连接少,但相比几千条真实独立样本还是太多,尤其是用 Flatten 接全连接时,参数会在全连接层爆炸。
解决:先砍参数再谈正则。把 AdaptAvgPool1d 换成 Flatten 是最容易踩的参数爆炸点,一旦换成全局池化,全连接层的参数量会从几百万降到几千。其次加 dropout,第三优先使用早停而不是靠轮数硬扛。如果这三板斧用完还过拟合,就回去增大 step 或者减少卷积核数量,而不是继续加正则项。数据量特别少时,甚至可以直接用单层卷积加线性输出,很多基线任务已经够用。
6. 进阶技巧:膨胀卷积扩大感受野,多步预测与异常检测的轻量做法
膨胀卷积是 1D-CNN 在时间序列上最实用的一招。它的思路很简单:让卷积核的采样点之间隔出空洞,每隔 dilation 个点取一个权重,感受野按指数增长,参数量不变。对一个 lookback 为 128 的序列,两三层层层递进的膨胀卷积就能覆盖整个窗口,而普通卷积至少要堆五六层。把基线模型里的普通卷积替换成膨胀卷积,只需要改卷积层的参数:
nn.Conv1d(in_channels=hid, out_channels=hid, kernel_size=3, padding=2, dilation=2, padding_mode='replicate')注意 padding 要等于 dilation * (kernel_size - 1) // 2 才能保持输出长度不变。dilation=2、kernel=3 的组合感受野是 5,但参数量和 kernel=3 完全一样。如果预测范围比较远,把 dilation 按 2、4、8 逐层递增,能指数级扩大覆盖范围。这种结构在时间序列异常检测里也好用,因为异常通常对应某个大尺度模式的突变,而不是单点噪声。
多步预测我推荐直接预测而不是递归。把 head 的 Linear 层输出改到 horizon 维,标签同步改成 (样本数, horizon),模型会一次输出整段预测。这样做的代价是 horizon 变大后拟合难度上升,但避免了误差滚雪球。还有一个我验证过的技巧:不要只盯着最后一个时间步的输出。拿训练好的模型取倒数第二层特征,做全局平均池化之后接一个 IsolationForest 或简单的阈值规则,就是一套轻量的时间序列异常检测方案。1D-CNN 在这里的角色是特征抽取器,把高维原始序列压缩成低维语义特征,再不需要单独训练一个复杂的检测模型。
这些年做时间序列模型,我养成的习惯是:测试集一直被锁到模型训练收敛之后才碰。调参可以调验证集,但最终结论永远以最后一次对测试集的推理为准。数据和代码存好版本,结论写清楚参数,不然后悔药都没得吃。希望这篇文章能帮你在自己的序列数据上少走几步弯路。
本文还有配套的精品资源,点击获取