拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

时间序列预测首选LSTM?TCN用膨胀卷积与残差连接实现更快训练

时间序列预测首选LSTM?TCN用膨胀卷积与残差连接实现更快训练

简介:压缩包内是基于Python的TCN时间卷积神经网络时间序列预测完整工程,核心任务是利用多个输入信号预测外汇中间价走势,并对比了该网络与长短期记忆网络的效果,适合有一定深度学习基础、从事时序数据建模的研究者或开发者。全部内容压缩为zip格式,共5个文件,大小仅1.04MB,包括2个说明文档、2个Jupyter交互式笔记本和1个Python脚本;其中交互式笔记本分别承担数据预处理与模型训练预测两个环节,Python脚本实现网络结构,配合数据目录和模型目录,可直接运行学习,也便于调整输入特征后迁移至其他序列预测任务。当前已有2392人学习,热度较高。作者在实际外汇数据上验证了模型表现,指出前期中间价预测一般,但后期走势拟合较好;同时提醒使用时应选择存在因果关系的输入和输出,避免将相关性误当因果性,并建议准备充足训练样本、采用提前停止等方法抑制过拟合,这些要点对将时间卷积网络应用到工业预测场景很有参考价值。

1. 从LSTM到TCN:时间序列预测为什么需要换一种网络结构

做时间序列预测的人,十有八九第一反应是LSTM或者GRU。我自己的项目里也踩过这个惯性——直到有一次用TCN(时间卷积神经网络)跑同样的航班客流数据,训练时间只有LSTM的三分之一,验证集误差反而低了近10%。TCN不是什么黑匣子,它的核心就是因果卷积、膨胀卷积和残差连接三件事:因果卷积保证只用过去预测未来,膨胀卷积在不加深网络的前提下成倍扩大感受野,残差连接让几十层的卷积网络也能稳定收敛。它能把一个长序列切成固定窗口直接出预测值,不需要像RNN那样逐步推进,天然适合并行计算。这篇笔记我会按自己复现和调参的完整路径来讲:TCN的结构为什么能打、数据怎么进模型、PyTorch里如何手写实现、训练和预测的参数怎么设,以及那些让我翻过车的坑。

2. TCN的结构拆解:膨胀卷积和残差为什么能替代RNN

2.1 因果卷积:如何保证只用历史数据预测未来

时间序列预测最基础的一条铁律是:测试阶段不能用未来信息。RNN通过循环结构天然满足这一点——t时刻的输出只依赖t-1时刻的隐状态。但普通卷积不行,一个kernel_size为3的卷积核在计算t时刻输出时,会同时看到t-1、t、t+1三个时刻的输入,这就是“泄漏未来”。

TCN用的因果卷积解决方式非常直接:对输入做左侧padding,把卷积核的覆盖范围整体向左偏移。例如kernel_size=3时,普通卷积是在输入两侧各pad 1位,而因果卷积只在左侧pad 2位,这样t时刻的输出实际只看到t-2、t-1、t三个时刻。在PyTorch里实现因果卷积不需要特殊的conv1d层:

import torch import torch.nn.functional as F from torch import nn def causal_pad(x, padding): # 只对时间维度左侧做padding,右侧不补 return F.pad(x, (padding, 0)) x = torch.randn(32, 8, 144) # batch=32, channels=8, 时间步=144 x_padded = causal_pad(x, padding=4) print(x_padded.shape) # torch.Size([32, 8, 148])

这里padding参数的含义是“向左补多少个零”。补pad位以后,卷积输出序列长度会变成输入长度+padding-kernel_size+1,要让输入输出等长,需要配合Select操作把末尾多出来的部分裁掉。实际项目中我一般会封装一个CausalConv1d类,把pad、conv和裁剪都包进去,这样在堆叠多层时不会反复出错。

提示:因果卷积的padding计算必须和卷积核尺寸联动。kernel_size=3、dilation=d时,左侧padding通常取(kernel_size - 1) * dilation,这样输出序列正好和输入序列等长。

2.2 膨胀卷积:感受野公式与kernel_size、dilation的配比

回到我开头提到的那个预测结果像白噪声的案例。那是一个逐小时的电力负荷数据,有明显的24小时周期。当时我用kernel_size=3、只堆了2层TCN,感受野只有7个时间步,别说周期,连趋势都学不出来。TCN解决这个问题靠的是膨胀卷积(dilated convolution)。

膨胀卷积的意思很简单:卷积核相邻参数之间插入空洞。dilation=1是普通卷积,dilation=2意味着卷积核每次跳过一个输入位置。它的价值在于:感受野随层数指数增长,但参数量不变。TCN层的感受野计算公式如下:

感受野 = 1 + sum( (kernel_size - 1) * dilation_i ),对第i层累加

如果每一层的dilation按2的指数递增,即(dilation=1, 2, 4, 8...),那么6层、kernel_size=4的TCN感受野是:

1 + (4-1) * (1+2+4+8+16+32) = 190个时间步

这意味着一根6层的TCN就可以覆盖接近200个历史时刻,而LSTM要想看到同样长的窗口,需要自己维护一个200维的隐状态,训练成本完全不同。感受野不能凭感觉定,我一般先在纸上算一遍:序列最长周期是多少、需要依赖多远的历史,然后反推层数。例如月度数据有12个月的周期,至少要让感受野超过12;小时数据有24小时周期,就按24起步。

2.3 残差连接与归一化:几十层网络不崩的秘密

膨胀卷积解决了视野问题,但网络一旦堆到十几层,梯度消失就会找上门。TCN沿用ResNet的残差连接做法:把每一层的输入和输出相加,再进入下一层。这样梯度可以跳过中间层直接回传,网络深度从几层扩展到几十层都没有问题。我在实际搭建模型时,会在每个TCN残差块内部做两次因果卷积、加ReLU和Dropout,并且加入权值归一化(WeightNorm),这一套组合在电力和金融数据上的稳定性都验证过。

class TCNBlock(nn.Module): def __init__(self, in_channels, out_channels, kernel_size, dilation, dropout): super().__init__() self.conv1 = nn.Conv1d(in_channels, out_channels, kernel_size, dilation=dilation) self.conv2 = nn.Conv1d(out_channels, out_channels, kernel_size, dilation=dilation) self.dropout = nn.Dropout(dropout) self.relu = nn.ReLU() # 输入输出通道不一致时,用1x1卷积对齐维度 self.resample = nn.Conv1d(in_channels, out_channels, 1) if in_channels != out_channels else None def forward(self, x): # 第一层因果卷积,只做左侧padding pad = (self.conv1.kernel_size[0] - 1) * self.conv1.dilation[0] out = F.pad(x, (pad, 0)) out = self.relu(self.conv1(out)) out = self.dropout(out) # 第二层因果卷积 out = F.pad(out, (pad, 0)) out = self.dropout(self.relu(self.conv2(out))) if self.resample is not None: x = self.resample(x) return out + x # 残差连接:逐元素相加

这段代码的参数要细致解释:dilation是膨胀系数,决定卷积核取输入的间隔;kernel_size控制单个卷积核的覆盖范围,两个一起决定这个block的感受野贡献。resample层的作用是当输入通道数不等于输出通道数时,用1x1卷积把维度对齐,否则残差相加会报形状错误。整个TCN网络就是把这个block按膨胀系数递增的方式串起来。

2.4 TCN和LSTM的选型边界:哪些场景适合换用TCN

别看TCN在很多基准上超过了LSTM,它不是万金油。从我的实际使用经验来说,TCN的强项在于中长窗口、有周期性、数据量充足的任务,比如负荷预测、交通流量预测和销量预测。训练过程可以整段并行,GPU利用率明显高。LSTM适合的状态是:序列本身很长且依赖关系是“记住某个遥远事件的影响”,例如异常检测里需要记住几个月前的某个模式;另外在线上推理时,LSTM的递推式结构更省内存,TCN需要缓存整个感受野窗口。

判断标准我一般用一条:如果序列里的规律主要靠“局部形状”判断,比如双十一前几天的销量爬坡,TCN更顺手;如果规律藏在“某个全局状态”里,LSTM更可靠。这也解释了为什么很多时间序列竞赛里TCN和LSTM的融合模型很常见——先把序列交给TCN提取局部特征,再把特征交给LSTM处理长期依赖,各干各的擅长部分。

3. 数据准备与窗口构造:滑窗参数如何决定预测效果

3.1 数据集加载与训练测试切分:航班客流示例

我复现TCN时最常用的是经典的AirPassengers月度航班客流数据,144条样本,有明确的趋势和12个月周期,特别适合验证模型有没有学到周期性。在本地直接用seaborn加载即可,不需要额外找数据源:

import numpy as np import pandas as pd import seaborn as sns df = sns.load_dataset("flights") series = df["passengers"].values.astype(float) print(f"样本量: {len(series)}, 前5个值: {series[:5]}") # 按8:2切分训练/测试,注意不能打乱顺序 train_len = int(len(series) * 0.8) train, test = series[:train_len], series[train_len:] print(f"训练集: {len(train)}, 测试集: {len(test)}")

这里最需要提醒的是切分方式:时间序列永远不能用随机打乱切分,否则模型在训练时偷看到了测试段的时间模式,评估分数全是假的。train_len按比例取前80%的连续样本,测试段取最后20%,这种切分方式保证测试段在时间上严格晚于训练段。

3.2 归一化的正确姿势:MinMaxScaler必须只fit训练集

TCN用ReLU作为激活函数,对输入尺度很敏感。时间序列的值如果从几十到几千波动,卷积计算出的特征图数值会非常大,梯度更新也跟着震荡。常见的做法是把数据缩放到[0, 1]区间。这里有一个我见过无数人翻车的细节:归一化时,必须只用训练集去fit,得到min和max,再把这个scaler同时应用到训练集和测试集。如果对整个序列(包含测试段)一起做MinMaxScaler,测试段的最大值会被提前“剧透”,训练时模型相当于看到了未来的尺度,这种数据泄漏会让测试误差偏低,线上部署后高得离谱。

from sklearn.preprocessing import MinMaxScaler scaler = MinMaxScaler(feature_range=(0, 1)) # 先切分、再fit训练集 train_scaled = scaler.fit_transform(train.reshape(-1, 1)).flatten() # 把scaler用在测试集上,不重新fit test_scaled = scaler.transform(test.reshape(-1, 1)).flatten() print(f"训练集范围: [{train_scaled.min():.3f}, {train_scaled.max():.3f}]") print(f"测试集范围: [{test_scaled.min():.3f}, {test_scaled.max():.3f}]")

预测完成之后,要用scaler.inverse_transform把归一化后的预测值还原成真实量纲,这样才能和原始数据做误差对比。fit_transform和transform的区别是:前者计算min/max并应用,后者只应用已保存的min/max,不重新计算。这是数据管线里最小的一个坑,却能造成最致命的误差失真。

3.3 滑动窗口数据集:input_window和output_window怎么选

TCN不能像LSTM那样一次吃一条任意长的序列,它输入的是一个固定长度的窗口。我将原始序列切成一批(X, y)样本:X是长度为input_window的历史窗口,y是未来output_window步的预测目标。窗口长度直接影响感受野的利用效率:input_window别超过感受野太多,否则多余的部分模型根本没看到;也别小于周期长度,否则周期性学不全。

def make_dataset(series, input_window=24, output_window=1): X, y = [], [] for i in range(len(series) - input_window - output_window + 1): X.append(series[i:i + input_window]) y.append(series[i + input_window:i + input_window + output_window]) return np.array(X), np.array(y) input_window, output_window = 24, 1 X_train, y_train = make_dataset(train_scaled, input_window, output_window) X_test, y_test = make_dataset(test_scaled, input_window, output_window) # 转成PyTorch需要的形状: (batch, channels, sequence_len) X_train = torch.from_numpy(X_train).float().unsqueeze(1) y_train = torch.from_numpy(y_train).float().squeeze(-1) print(f"训练样本形状: {X_train.shape}, 标签形状: {y_train.shape}")

output_window=1是单步预测,训练简单、误差较小;如果做多步预测,output_window设成目标步数即可,但误差会随步数增加而放大。input_window的经验值是至少覆盖两个完整周期。航班数据周期是12个月,我一般取24,刚好两年窗口;逐小时电力数据周期是24小时,窗口通常会取168(一周)或336(两周)。滑动窗口构造时还有个容易被忽略的点:相邻窗口的数据高度重叠,这会让训练样本之间不独立,影响模型泛化,但样本量不足时还是要靠这种重叠来扩充数据。

4. 从零搭建TCN模型:PyTorch手写完整训练闭环

4.1 完整TCN网络实现:残差块堆叠与全连接输出

在真实项目里我不想每次都重复写残差块,所以会把上一章的TCNBlock组装成完整的TCN类。核心参数有四个:num_channels定义每一层的通道数、kernel_size定义卷积核大小、dropout控制随机失活比例、num_levels控制层数(进而控制感受野)。每个残差块之间,膨胀系数按2**level递增:

class TCN(nn.Module): def __init__(self, input_channels, num_channels, kernel_size, dropout, num_levels, output_steps): super().__init__() self.blocks = nn.ModuleList() for level in range(num_levels): dilation = 2 ** level in_ch = input_channels if level == 0 else num_channels[level - 1] out_ch = num_channels[level] block = TCNBlock(in_ch, out_ch, kernel_size, dilation, dropout) self.blocks.append(block) self.fc = nn.Linear(num_channels[-1], output_steps) def forward(self, x): # x shape: (batch, input_channels, seq_len) for block in self.blocks: x = block(x) # 取最后一个时间步的特征 x = x[:, :, -1] return self.fc(x)

参数说明:num_levels不宜一次加太大,层数越多感受野越大,但训练越慢。航班数据用num_levels=6、kernel_size=4,感受野约190,覆盖24个月窗口绰绰有余;如果数据量不足,层数再高就会过拟合。output_steps是预测未来的步数,单步预测时设为1,12步预测就设为12。

4.2 训练配置:损失函数、优化器和早停的取舍

训练TCN和训练LSTM很像,但有几个关键差异。第一是loss选择:单步预测用MSE没问题,但有明显尖峰的数据集(比如节假日销量激增)用HuberLoss更好,它在大误差处的梯度是恒定的,不会因为一个极端值把整个模型学歪。第二是优化器:我用Adam的标配是lr=0.001、weight_decay=1e-4,RNN在这配置下容易梯度爆炸,但TCN因为残差连接的存在,很少出现爆炸问题。第三是BatchSize:TCN是卷积结构,同样的batch下内存占用比LSTM小,可以适当调大,我在航班数据上直接用64。

from torch.optim import Adam from torch.nn import MSELoss, HuberLoss model = TCN( input_channels=1, num_channels=[16, 32, 64, 64, 32, 16], kernel_size=4, dropout=0.2, num_levels=6, output_steps=1 ) optimizer = Adam(model.parameters(), lr=0.001, weight_decay=1e-4) criterion = HuberLoss(delta=1.0) # 对尖峰数据更稳 # 训练循环简写 for epoch in range(200): model.train() optimizer.zero_grad() pred = model(X_train) loss = criterion(pred, y_train) loss.backward() optimizer.step() if epoch % 20 == 0: print(f"epoch {epoch}, loss {loss.item():.6f}")

num_channels从16开始逐层增加再逐层减少,形成一个对称的“梭形”结构:前几层提取局部细节,中间层扩大感受野,后几层把高维特征压缩。dropout=0.2是经验值,不建议一开始就用0.5,窗口序列本身信息密度不高,dropout太狠容易欠拟合。

4.3 测试集评估与真实值还原:预测曲线为什么能对上周期

训练结束后,最关键的一步是检查模型在测试段的表现。测试段是模型从未见过的后20%数据,能直接反映模型真实泛化能力。评估分两步:先把归一化的预测结果还原成真实客流人数,再和真实值计算MAE和MAPE。

model.eval() with torch.no_grad(): y_pred_scaled = model(X_test).numpy().flatten() # 还原真实尺度 y_pred = scaler.inverse_transform(y_pred_scaled.reshape(-1, 1)).flatten() y_true = test_scaled[input_window:] # 注意对齐窗口偏移 y_true = scaler.inverse_transform(y_true.reshape(-1, 1)).flatten() mae = np.mean(np.abs(y_true - y_pred)) mape = np.mean(np.abs((y_true - y_pred) / y_true)) * 100 print(f"MAE: {mae:.2f} 人, MAPE: {mape:.2f}%")

航班数据在这组参数下,MAPE通常在3%到6%之间。如果MAPE超过10%,优先检查感受野是不是覆盖了周期,其次是通道数是否太小。还原真实量纲这一步经常有人漏掉,直接拿归一化的误差去报告,导致误差数值小得好看却没有业务意义。

5. TCN时间序列预测的5个高频坑:从数据泄漏到递归误差累积

5.1 对称padding导致未来信息泄漏:测试集loss低到不真实

现象:训练和测试loss都低到0.001级别,预测曲线和真实曲线几乎完全重合,稍微平移一点。这时候别高兴太早,大概率是泄漏了。

原因:实现时用了PyTorch内置的nn.Conv1d默认行为,或者没有做左侧padding,而是在序列两端都补零。TCN变成了普通卷积,t时刻的输出混入了t+1时刻的未来信息。序列预测本质上变成了“抄近道”,测试评估失真。

解决:所有卷积层前强制用F.pad(x, (pad, 0))做左填充,并写成独立的CausalConv1d类,不允许普通Conv1d直接出现。然后做一个“戳穿测试”:把输入序列的最后一个时刻改成极大值,看预测结果变不变——TCN的预测应该完全响应这个变化,而泄漏模型会提前在t-1时刻就出现异常。

5.2 归一化时把测试集混进去fit:回测漂亮上线崩盘

现象:线下回测误差MAPE只有2%,上线后第一周误差飙到20%以上,预测曲线整体比真实值高一大截。

原因:MinMaxScaler.fit时用的是全部序列的min和max,测试段的最大值被模型提前看到了。测试段的取值范围被人为压缩到和训练段一致,真实上线时新数据的max不在训练范围内,scaler把新值映射到区间外,预测自然全面偏移。

解决:雇一个严格的管线顺序:先train_test_split,再fit_transform(train),最后transform(test)。并且保存scaler时用joblib.dump单独存文件,预测服务加载时,只能加载训练阶段存下的scaler,任何重新fit都不允许。

5.3 感受野小于周期长度:模型把周期学成了噪声

现象:预测曲线平坦,几乎看不出周期性,误差集中在波峰和波谷,但平均值不高。

原因:感受野是190个步长,但序列周期是365天或者12个月,TCN根本看不到一个完整的周期,只能把局部波动当噪声拟合。

解决:先用代码打印感受野:rf = 1 + sum((kernel_size-1) * 2**level for level in range(num_levels)),再对比输入序列的自相关图,找到最显著的周期长度。感受野至少要达到周期长度的2倍,否则就增加num_levels或kernel_size。航班数据周期12,配置6层、kernel_size=4已经到190,绰绰有余;逐小时负荷数据周期24,推荐8层、kernel_size=5。

5.4 多步递归预测误差滚雪球:越远的预测越接近均值

现象:做未来12个月预测时,第3个月之后预测曲线开始趋于平缓,最终变成一条接近历史均值的直线。

原因:用的是“递归多步预测”——把第1步的预测值当成输入再预测第2步,误差逐步累积并放大。TCN的卷积结构本身没有不确定性建模能力,每一步的误差被当作真实值送进下一个窗口,最后模型倾向于输出保守的均值。

解决:多步预测时改成“直接多步输出”,把output_steps设为12,模型一次输出12个预测值,误差不传递。如果坚持递归预测,可以在训练时给输入加少量高斯噪声(噪声尺度取训练集标准差的5%),模拟预测误差的累积环境,让模型对输入扰动更鲁棒。

5.5 损失函数被尖峰数据带崩:HuberLoss替代MSE

现象:训练loss出现一个突然的尖峰,之后模型输出恒为历史均值。回看数据,训练集里有一个远高于其他值的异常样本。

原因:MSE对大误差样本的梯度是线性的,一个极大值会把梯度推得非常大,Adam虽然能调整步长,但网络权重被一次性推到远离最优解的区域。TCN的共享卷积核让这种破坏被放大到所有时间步。

解决:把MSELoss换成HuberLoss(delta=1.0)。delta以内的误差按平方处理,delta之外按线性处理,梯度不会超过delta这个阈值,单个异常样本的影响被天然压制。工业场景我几乎默认用HuberLoss,除非数据确认没有异常值才用MSE。

6. 验证多步预测的最后一公里:滚动回测与残差分析

多步预测的误差评估和单步完全不同。很多人在单步预测上效果不错,就以为多步也稳了——真做12步预测时,滚动回测才是最接近线上场景的验证方式。所谓滚动回测,是从测试集起始点开始,每次用历史窗口预测未来12步,然后把窗口向后滚动一步,重复这个过程。这比一次性在测试集上预测更能暴露误差累积问题。

滚动回测的实现技巧是:每滚动一步,把窗口的真实值移动一个位置,而不是把上一轮的预测值塞进窗口。这样评估的是“在每一步都能拿到真实历史的情况下,模型每一步的预测能力”,和线上真正面临的情况略有区别,但它能帮你分离“模型拟合能力差”和“递归误差累积”两个问题。

做完滚动回测,还要看残差序列是否还有模式。把y_true - y_pred按时间顺序画出来,如果残差在某个时间段系统性为正、另一个时间段系统性为负,说明模型漏掉了某个周期性因子;如果残差里还有明显的周期振荡,说明感受野或通道数不够。残差应当是围绕0随机波动,没有明显结构,才算模型把时序规律榨干了。这一步排查完,再朝着加大感受野的方向调参,通常不会白费力气。

我个人的习惯是,每次调参先打印感受野、序列周期、损失函数类型这三个信息。感受野不够就加层数;周期没抓到就加kernel_size或input_window;损失被尖峰带崩就用HuberLoss。这套排查流程在负荷预测和销量预测上反复验证过,最后补一句:时间序列预测没有通用银弹,但把TCN的这三个关键维度先摸透,至少能让你少走我走过的弯路。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表