☰
TCN+Transformer时间序列预测模型核心实现与调参指南
2026/10/21 3:37:03 网站建设 项目流程

做时间序列预测的同行,大概率都经历过这种纠结:LSTM训练慢、梯度容易爆,纯Transformer结构复杂、对小样本不友好,CNN又总觉得少了点全局视野。我自己在几个项目里反复横跳之后,慢慢发现TCN和Transformer组合起来的思路越来越常见,代码框架也渐渐成型。这篇就把一个完整的TCN-Transformer时间序列预测模型的代码功能拆开讲清楚,包括每一层在干什么、参数为什么这么设、训练里哪些环节最容易被忽略。

这篇内容适合三类人:一是刚开始接触深度学习时间序列、想找一个比LSTM更稳的基线模型的人;二是已经在用Transformer做预测、但对局部时序特征提取不满足的人;三是纯粹想读懂别人开源代码、准备改造成自己业务模型的算法工程师。我默认你熟PyTorch,理解卷积和注意力机制的基本概念,但不用你背公式。

1. 先把模型定位搞清楚:为什么是TCN+Transformer

1.1 单靠Transformer不够顺手的几个场景

Transformer在自然语言处理里确实强,但直接搬到时间序列上,有几个情况让我觉得别扭。

第一个是局部模式识别。Transformer的核心是注意力机制,它擅长捕捉长距离依赖,但本质上是一种“全局加权”,对局部趋势、短期波形、突然拐点这类细节,注意力计算出来的权重往往是分散的。我做电力负荷预测的时候,每天早高峰、晚高峰的用电形态非常规律,这种局部形状特征用几层卷积提取反而又快又准,Transformer反而容易把这些细节“平均化”。

第二个是计算效率和序列长度。标准Transformer是平方级复杂度,序列越长,计算成本涨得越厉害。做长时间序列预测动不动就是几百个时间步,直接硬上Transformer,训练显存和耗时都会很难看。虽然有一些线性注意力、稀疏注意力的变体,但工程落地时复杂度还是比卷积高。

第三个是小样本场景。Transformer这种大模型范式,需要足够多的数据量才能发挥优势。很多行业场景,数据量就几千条、几万条,纯Transformer很容易过拟合,验证集表现反而不如结构更简单的模型。

1.2 TCN在这里补的是什么短板

TCN的核心思路就是时间卷积网络,本质上是带因果约束的膨胀卷积。所谓因果,就是计算t时刻的输出时,只看t时刻以及之前的信息,绝不用未来数据,这一点对预测任务来说是硬约束,不能乱。膨胀卷积则解决了普通卷积感受野太窄的问题,通过逐步加大的膨胀率,用很少的层数就能看到很长的历史窗口。

TCN最大的优势就是快、稳。卷积天然适合并行计算,一个batch的训练速度比LSTM快很多。同时它没有像LSTM那样依赖时间步递归,梯度流动更直接,训练时也不容易遇到梯度消失。而且TCN的感受野计算是透明的,理论上能看到多长的历史,公式一算就能知道,这让设计模型时心里非常有底。

Transformer补的则是TCN的盲区。TCN虽然能覆盖长历史,但本质上还是局部卷积的堆叠,对“远处某个特征和当前状态的关系”这种远程依赖建模能力有限。比如股票数据里,几天前的某个成交量异动可能对今天的走势有长远影响,这种影响靠卷积逐层传递容易衰减,靠注意力直接关联则更自然。

1.3 整体架构一句话版

把两者放在一起的典型结构就是:TCN做前端特征提取器,负责从原始序列里抠出局部模式和短期趋势;Transformer编码器做后端序列建模器,负责在TCN输出的特征序列上捕捉远程依赖;最后接一个全连接输出层,把编码结果映射成未来若干步的预测值。

这个架构的好处在于分工明确。TCN先一步把序列“压缩清洗”成更紧凑的特征表达,Transformer在这个更干净的特征空间里做全局关联,计算量比直接在原始序列上做注意力小得多。我实际测试中,这种前后串联结构比单独用任何一个模型,在多个公开数据集上都有稳定提升,尤其在中长期预测上,效果更明显。

2. TCN部分代码功能拆解

2.1 从TCNBlock开始看起

TCN的核心模块是一个残差块,我习惯叫它TCNBlock。每个Block包含两层膨胀卷积、激活函数、Dropout和残差连接,是整个网络的基石。我看代码时会先找这个模块,理清它才能理解整个TCN。

import torch import torch.nn as nn from torch.nn.utils import weight_norm class TCNBlock(nn.Module): def __init__(self, in_channels, out_channels, kernel_size, dilation, dropout=0.2): super().__init__() self.padding = (kernel_size - 1) * dilation self.conv1 = weight_norm(nn.Conv1d(in_channels, out_channels, kernel_size, padding=self.padding, dilation=dilation)) self.conv2 = weight_norm(nn.Conv1d(out_channels, out_channels, kernel_size, padding=self.padding, dilation=dilation)) self.relu = nn.ReLU() self.dropout = nn.Dropout(dropout) self.downsample = None if in_channels != out_channels: self.downsample = weight_norm(nn.Conv1d(in_channels, out_channels, 1)) def forward(self, x): residual = x if self.downsample is None else self.downsample(x) out = self.relu(self.conv1(x)) out = self.dropout(out) out = self.relu(self.conv2(out)) out = self.dropout(out) # 因果卷积裁剪,去掉padding引入的未来信息 if self.padding > 0: out = out[:, :, :-self.padding] return self.relu(out + residual)

这个模块里最容易出问题的就是padding和裁剪。为了保持序列长度不变,膨胀卷积需要加padding,padding的量是(kernel_size - 1) * dilation。如果不做裁剪,卷积核会看到未来的数据,因果性就被破坏了。所以forward里要在最后把末尾的padding部分切掉。

2.2 堆叠多层扩大感受野

单个Block只有两层卷积,能看到的范围非常有限,所以要把多个Block串起来,每个Block的膨胀率逐层加倍,这样感受野才会指数级扩大。这是TCN设计的关键。

class TemporalConvNet(nn.Module): def __init__(self, num_channels, kernel_size=3, dropout=0.2): super().__init__() layers = [] num_levels = len(num_channels) for i in range(num_levels): in_channels = num_channels[i - 1] if i > 0 else num_channels[0] out_channels = num_channels[i] dilation = 2 ** i layers.append(TCNBlock(in_channels, out_channels, kernel_size, dilation, dropout)) self.network = nn.Sequential(*layers) def forward(self, x): return self.network(x)

这里num_channels是一个列表,比如[64, 128, 256],表示三层Block的输出通道数分别是64、128、256,膨胀率分别是1、2、4。感受野计算公式是:R = 1 + (kernel_size - 1) * (2^L - 1),其中L是层数。以kernel_size=3、层数为3举例,感受野就是1 + 2 * (8 - 1) = 15,也就是每个输出位置能看到过去15个时间步。

踩坑提醒:数值上感受野是“理论上能看到”,但实际效果还受残差连接、激活函数、dropout的影响,离得远的特征会被稀释。所以层数不是越多越好,要按任务的历史长度需求来配。我一般会按公式反推一个层数,再留一两层余量,而不是无脑堆深。

2.3 权重归一化和残差连接的作用

TCNBlock里用了weight_norm。权重归一化是给卷积核做约束,把权重分解成方向和长度两个部分,训练时分别更新。这么做的好处是训练更稳定,不容易因为梯度震荡导致loss曲线乱跳。它和BatchNorm不冲突,甚至经常一起用。我个人的体验是,时间序列预测任务里WeightNorm比BatchNorm更稳,因为BatchNorm对batch大小和分布波动敏感,而时序数据经常存在分布偏移。

残差连接解决的是深层网络的退化问题。每层Block学习的是“输入和目标的差”,而不是直接学习完整的映射,这样梯度回传时有一个“高速公路”,不容易在深层网络中消失。通道数不一致时,用1x1卷积做下采样对齐通道数,这在下采样分支里是标准操作。

关于Dropout的位置也要注意。我在每个卷积后接一个ReLU,ReLU后再接Dropout,这是比较常见的做法。Dropout率在0.1到0.3之间都算合理,看训练集大小。如果数据量大,dropout可以小一些,0.1或0.15;数据少就加大到0.2以上,防止过拟合。

3. Transformer编码器部分的代码细节

3.1 位置编码:给序列加上空间坐标

Transformer本身没有顺序概念,注意力计算时交换两个token的位置,计算结果是一模一样的。这放在语言里不对劲,放在时间序列里更是致命的,因为时间顺序就是序列的全部意义。所以必须加位置编码。

class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len=1000): super().__init__() pe = torch.zeros(max_len, d_model) position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1) div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-torch.log(torch.tensor(10000.0)) / d_model)) pe[:, 0::2] = torch.sin(position * div_term) pe[:, 1::2] = torch.cos(position * div_term) pe = pe.unsqueeze(0).transpose(0, 1) self.register_buffer('pe', pe) def forward(self, x): return x + self.pe[:x.size(0), :]

这个正弦余弦编码是经典方案,它有几个很好的性质:不同位置编码向量之间可以互相线性表示,位置i+k的编码可以由位置i的编码通过变换得到,这让模型有机会学习到位置之间的相对关系。max_len要设得比可能的序列长度大,否则输入变长就报错了。

我在实际项目里也试过用可学习的位置编码,就是初始化一个nn.Embedding(max_len, d_model),效果和正弦编码差不多。如果数据量特别大,可学习的编码可能略微占优。但正弦编码的好处是不用训练、泛化性更强,对超出训练长度的位置也有合理的编码。基础版本先用正弦编码,够稳。

3.2 多头注意力和编码器层实现

核心组件是多头注意力。头数的作用是让模型从不同子空间看序列,有的头关注短期波动,有的头关注长期趋势,最后拼起来综合判断。

import torch.nn.functional as F def scaled_dot_product_attention(q, k, v, mask=None): d_k = q.size(-1) scores = torch.matmul(q, k.transpose(-2, -1)) / torch.sqrt(d_k) if mask is not None: scores = scores.masked_fill(mask == 0, -1e9) attn = F.softmax(scores, dim=-1) return torch.matmul(attn, v), attn class MultiHeadAttention(nn.Module): def __init__(self, d_model, num_heads): super().__init__() self.num_heads = num_heads self.d_k = d_model // num_heads self.wq = nn.Linear(d_model, d_model) self.wk = nn.Linear(d_model, d_model) self.wv = nn.Linear(d_model, d_model) self.wo = nn.Linear(d_model, d_model) def forward(self, x): batch, seq_len, _ = x.shape Q = self.wq(x).view(batch, seq_len, self.num_heads, self.d_k).transpose(1, 2) K = self.wk(x).view(batch, seq_len, self.num_heads, self.d_k).transpose(1, 2) V = self.wv(x).view(batch, seq_len, self.num_heads, self.d_k).transpose(1, 2) attn_out, _ = scaled_dot_product_attention(Q, K, V) attn_out = attn_out.transpose(1, 2).contiguous().view(batch, seq_len, self.d_model) return self.wo(attn_out)

d_model是模型宽度,num_heads要能整除d_model,每个头的维度是d_model // num_heads。比如d_model=64、num_heads=4,每个头就是16维。用view和transpose把矩阵拆成多头是PyTorch里非常标准的操作,注意最后一定要.contiguous()再view,否则会报错。

Transformer编码器层不是只有注意力,而是一个组合模块。标准的EncoderLayer包括:多头注意力、残差连接、层归一化、前馈网络、残差连接、层归一化。PyTorch自带nn.TransformerEncoderLayer,当然你可以直接用,不用自己写。但我更建议至少完整读一遍实现代码,知道每一层到底做了什么,后面要改细节时才不会一头雾水。

class TransformerEncoderLayer(nn.Module): def __init__(self, d_model, num_heads, dim_ff, dropout=0.1): super().__init__() self.attention = MultiHeadAttention(d_model, num_heads) self.norm1 = nn.LayerNorm(d_model) self.ff = nn.Sequential( nn.Linear(d_model, dim_ff), nn.ReLU(), nn.Dropout(dropout), nn.Linear(dim_ff, d_model), nn.Dropout(dropout), ) self.norm2 = nn.LayerNorm(d_model) self.dropout = nn.Dropout(dropout) def forward(self, x): attn_out = self.attention(x) x = self.norm1(x + self.dropout(attn_out)) ff_out = self.ff(x) x = self.norm2(x + self.dropout(ff_out)) return x

dim_ff通常设成d_model的2到4倍,我常用128或256。前馈网络的作用是对每个位置的编码做非线性变换,相当于“逐位置的MLP”,它不跨位置交互,但是能提升模型的表达能力。层归一化放在残差之后是标准Transformer的结构,注意别和“先归一化再 attention”的Pre-LN结构搞混,两种结构都有用,但影响训练稳定性和最终精度,这里按标准版来就行。

3.3 输出头与预测值生成

Transformer编码器输出的形状是(batch, seq_len, d_model)。时间序列预测一般有两种做法:一是取最后一个时间步的隐藏状态,接全连接直接输出未来预测值;二是把所有时间步的编码结果flatten或者做全局池化,再接全连接。第一种做法更常见,因为预测当前状态时最后一步的信息最浓缩,含有整个序列的最新信息。

class TCNTransformer(nn.Module): def __init__(self, input_size, tcn_channels, kernel_size, d_model, num_heads, num_layers, dim_ff, output_size, dropout=0.1): super().__init__() self.tcn = TemporalConvNet(tcn_channels, kernel_size, dropout) self.input_proj = nn.Linear(tcn_channels[-1], d_model) self.pos_encoder = PositionalEncoding(d_model) encoder_layer = TransformerEncoderLayer(d_model, num_heads, dim_ff, dropout) self.transformer = nn.TransformerEncoder(encoder_layer, num_layers) self.output_fc = nn.Linear(d_model, output_size) def forward(self, x): # x: (batch, seq_len, input_size) x = x.permute(0, 2, 1) # (batch, input_size, seq_len) tcn_out = self.tcn(x) # (batch, channels, seq_len) tcn_out = tcn_out.permute(2, 0, 1) # (seq_len, batch, channels) tcn_out = self.input_proj(tcn_out) # (seq_len, batch, d_model) tcn_out = self.pos_encoder(tcn_out) trans_out = self.transformer(tcn_out) # (seq_len, batch, d_model) last_hidden = trans_out[-1, :, :] # (batch, d_model) return self.output_fc(last_hidden)

这里有一个维度转换的细节,我会特别注意:TCN是Conv1d,输入格式是(batch, channels, seq_len);Transformer的输入格式是(seq_len, batch, d_model)。中间必须做两次permute,很多人第一次写这个模型时就死在这,维度对不上直接爆炸。input_proj是为了把TCN输出的通道数调整成d_model,这一步不能省。

4. 训练流程与超参数选择

4.1 数据预处理和窗口切分

数据归一化是预测任务里的重中之重。时间序列的标准做法是用MinMaxScaler或StandardScaler,在训练集上拟合,然后同一套参数去缩放验证集和测试集。这里有一个特别容易踩的坑是数据泄漏:如果你用全部数据去fit scaler,再做切分,那么测试集的信息就已经混进了训练阶段,结果会虚高。正确的做法是,先按时间顺序切分原始数据,再分别对训练集fit、对验证集和测试集transform。

窗口切分也是一个工程细节。常见思路是给定输入长度seq_len和预测长度pred_len,用一个滑动的窗口在数据上滚动生成样本对。假设我有10000条数据,seq_len取96,pred_len取24,那么样本数量大约在9000左右。样本之间是有重叠的,这是正常的,能有效扩大样本量,但要注意别让相邻样本完全重复给模型“作弊”的机会。

def create_windows(data, seq_len, pred_len): X, y = [], [] for i in range(len(data) - seq_len - pred_len + 1): X.append(data[i:i + seq_len]) y.append(data[i + seq_len:i + seq_len + pred_len]) return np.array(X), np.array(y)

如果做多变量预测,data的shape是(总长度, 特征数),X的shape是(样本数, seq_len, 特征数),y的shape是(样本数, pred_len, 预测目标数)。如果你的任务是单步预测,pred_len=1即可,输出层只需要一个值。

4.2 训练循环与学习率策略

训练过程的套路其实和大多数深度学习任务一致,但有几点针对时序任务的特殊性。优化器我首选Adam,配上weight_decay,也就是L2正则,防止过拟合。学习率是这类模型最敏感的超参数之一,0.001是一个安全的起点,但我遇到过几次直接爆掉的loss,后面改用学习率调度器才稳定下来。

optimizer = torch.optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-5) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', factor=0.5, patience=5, verbose=True ) criterion = nn.MSELoss()

ReduceLROnPlateau的思路简单说:如果验证集loss连续5个epoch不下降,就把学习率减半。这种调度策略特别适合时间序列任务,因为数据分布波动大,fixed学习率很容易在一个局部震荡区反复横跳,降低学习率往往能跳出怪圈。我自己用的patience是5到8,factor是0.5,效果比较稳。

训练循环里,每轮epoch结束都要在验证集上评估一次,记录验证loss。如果验证loss连续多个epoch不降,就触发early stopping,保留最优模型参数。这能有效避免模型过拟合。训练轮数可以设100到200,配合early stopping,一般30到50轮就能收敛。

4.3 一套能直接用的基础配置

我给一个比较通用的配置,适合中等规模的数据集,比如几千到几万条样本、几个到十几个特征:

参数推荐值说明
kernel_size3或5太小感受野不够,太大参数量上去
tcn_channels[64, 128, 256]通道数逐层翻倍,常规操作
d_model64或128特征宽度,不宜过大
num_heads4或8必须能整除d_model
num_layers2到4Transformer编码器层数
dim_ff128或256前馈网络宽度
dropout0.1到0.2根据数据量调整
seq_len96或168历史窗口长度,任务相关
pred_len24或48预测长度,业务决定
batch_size32或64显存允许可以更大
learning_rate0.001Adam配套常用值

这套配置不是最优解,但绝对是一个稳妥的起点。拿到一个新数据集,先用这套基线跑通,然后通过实验调整seq_len、d_model和dropout这几个最敏感的参数。一次只动一个变量,记录结果对比,别同时改好几个,否则根本说不清是哪个改动带来的提升。

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

5.1 训练曲线异常的排查顺序

训练loss一直是nan或者突然变nan,这种问题我遇到太多次了。首先检查数据里有没有nan值,这看起来基础,但有时候数据清洗不干净就会这样。其次检查学习率,如果学习率太大,loss会在某个epoch直接炸到inf,然后变成nan。再检查模型输出的维度对不对,上文的维度不匹配问题就可能引发各种各样奇怪的报错。

验证集loss降不下去,但训练集loss一直降,典型过拟合信号。我会三管齐下:加大dropout,提高weight_decay,同时减小模型规模。很多新手一上来就提高模型复杂度,其实在小数据集上,越简单越好。要让模型误差“大到一定程度”才开始学规律,而不是死记训练集。

模型完全不动,训练loss几乎没有变化,这种问题一般出在初始化或者数据尺度没对齐。我建议先用StandardScaler做归一化,再检查TCN是否收到了预期的输入形状。我会在模型forward里临时打印每一步的shape和均值,快速定位是哪个环节把信息弄丢了。

5.2 预测结果滞后问题

滞后是时间序列预测里被讨论很多的现象,体现为预测曲线比真实曲线整体平移了一小段时间。在金融数据里,这往往意味着模型学会了“跟随前一个值”而不是“提前预判”。滞后问题背后的常见原因有两个:一是模型过于依赖最近的历史值,注意力全放在了最后几个时间步上;二是序列本身噪声大,模型觉得“照着最近的值抄”是最安全的策略,宁可多错一点也不冒险。

我的处理经验是:加重TCN部分的权重,把TCN层数加多一点,让模型先把历史形态看透,再把特征交给Transformer。另一个办法是换损失函数,MSE对微小偏差不敏感,改用Smooth L1 Loss或者直接预测“下一段序列的一阶差分”而不是原始值,有些场景会改善滞后。

5.3 序列长度的经验

序列长度越大,不一定效果越好。我做过一组对比实验,seq_len从48、96、168一直加到336,结果96到168之间效果最好,336反而掉了。原因不难理解:太长的输入引入太多历史噪音,注意力的有效注意力范围是有限的,模型被迫在无关的历史信息上消耗能力。而且训练时间、显存开销也随序列长度增长,尤其在Transformer部分,收益不成正比。

另一个经验是不同预测长度对应不同最优输入长度。预测长度短,输入长度可以短;预测长度长,输入长度也要适当拉长,比例为输入长度约为预测长度的4到8倍是比较合理的起点。具体要结合数据特征,比如电力负荷有日周期性,序列长度至少要涵盖一个完整周期,否则模型看不到周期规律。

6. 训练收敛之后还能怎么优化

模型跑通之后,想要再提升性能,我建议按优先级做三件事。第一件事是特征工程,加日期特征、节假日标记、滞后特征、滚动统计特征,这些带来的提升往往比调模型结构来得快。时间序列预测不是纯靠模型魔法,业务知识转换成特征,效果立竿见影。

第二件事是考虑预测目标。直接预测原始值之外,也可以预测趋势,或者对数据进行季节性分解再做预测,让模型只学习其中一部分。多任务学习的思路也可以用,比如同时预测多个目标,让模型共享底层特征,提升泛化性。

第三件事是集成和不确定性估计。多个模型训练出来取平均,能明显压低方差。不确定性估计则是对预测结果给出置信区间,对业务决策更实用。这部分代码实现不复杂,但价值很高,尤其当你需要把结果交给业务方的时候。

我需要坦白的一点是,TCN+Transformer并不是所有场景下的最优模型。它对中等长度、有周期性规律的数据非常友好,但对超高维、超长序列的场景,可能需要更复杂的结构,比如变分推断或者状态空间模型。没有银弹,关键是理解每个模块为什么存在、解决什么问题,然后根据你的数据去组合和取舍。

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

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

立即咨询