TCN vs LSTM时间序列预测:因果膨胀卷积、感受野与RMSE对比实战
简介:面向初级及以上数据科学从业者与机器学习爱好者的时间序列预测项目文档,围绕Python与TensorFlow实现时间卷积神经网络(TCN)展开,重点解决长序列建模中RNN易出现的梯度消失与性能衰减问题。内容从环境依赖、模拟外汇数据生成、归一化与时间步切分讲起,逐步搭建因果卷积TCN,并给出训练、预测和可视化流程;同时实现LSTM对照模型,用对比图呈现两类网络在外汇趋势预测上的效果差异,便于读者理解TCN捕捉长程依赖的思路。资源包仅含1个docx文件,约31KB,以图文与代码片段承载完整项目说明,篇幅紧凑,适合作为可跟随复现的代码笔记。已有173人学习,读者可据此掌握数据预处理、模型构建、训练评估与参数调整的完整链路,并迁移到金融等连续数值预测任务。
1. 从一条跑偏的预测曲线说起:TCN 这套方案到底解决什么
时间序列预测这个题目,十个人里有八个第一反应是上 LSTM。但在窗口拉到几十上百步、又要在两周内跑出结果的时候,LSTM 训练慢、后期走势跑偏的问题就会集中暴露。这套 Python 方案换了个思路:用时间卷积神经网络(TCN),靠因果卷积保证不偷看未来,靠膨胀卷积把感受野撑开,再并排搭一个 LSTM 作对照组,用同一份数据、同一个 RMSE 口径说话。它解决的核心问题就一个——让一份长序列预测的课程设计或毕业设计,具备完整的数据流水线、可复现的模型代码、经得起追问的对比结论。适合数据科学方向的学生,也适合需要给金融时序做基线的从业者拿去改。
2. 任务定义与数据流水线:从一串价格到可喂给网络的 3D 张量
2.1 需求边界:预测目标、窗口长度与指标口径先定死
先把要预测的东西说死:输入是一段长度为time_step的历史价格,输出是紧接着的下一日价格,单变量、单步预测。这个边界一旦松动,后面所有代码的形状都会跟着变——多变量要改输入通道数,多步要改输出维度,很多项目做到一半发现需求变了,就得从头再捋一遍。我一般会在动手前把三件事写在纸上:数据有多少条、窗口开多长、指标用哪个。这份方案的答案是 1000 条日频记录、窗口 10、指标用 RMSE 并且回到原始价格量纲。
数据来源可以是公开渠道拿到的外汇历史行情,也可以先用模拟数据把管线跑通。两者的区别只在于要不要额外做缺失值处理、交易日对齐和节假日跳空修正。对一份要交付的方案来说,先跑模拟数据的收益很直接:把数据清洗的不确定性隔离掉,保证任何环境、任何时间点重跑都能得到同一条曲线,答辩现场演示不会因为数据源抽风翻车。
2.2 模拟数据生成:为什么先用随机游走打底
直接对着一份真实行情调模型,是很难判断问题出在数据上还是模型上的。用累积求和的随机游走打底,序列天然带着趋势性、非平稳性和不可预测的噪声成分,形态上很接近真实价格,但它完全由随机种子决定,可复现。
import numpy as np import pandas as pd np.random.seed(42) # 固定种子,任何人任何机器跑出来都是同一条曲线 dates = pd.date_range(start='2020-01-01', periods=1000, freq='D') # 正态噪声累积求和:单日波动小,长期形成趋势,形态接近价格序列 prices = np.random.normal(loc=1.0, scale=0.01, size=len(dates)).cumsum() + 100 data = pd.DataFrame({'Date': dates, 'Price': prices}) data.set_index('Date', inplace=True) data.to_csv('forex_data.csv')loc=1.0是日均变动的中心位置,scale=0.01控制日波动幅度,cumsum()把彼此独立的日增量累加成一条有方向感的曲线,+100只是给一个合理的起始价位,让数值落在真实汇率的量级附近。换成真实数据时,只要保证输出是同样结构的单列 CSV,后面所有代码一行都不用改。这就是把数据接入点单独隔离出来的好处。
2.3 归一化与滑窗:scaler 的 fit 位置决定成败
这一段是整个流水线里最容易写错的地方。很多人习惯先把全量数据丢进MinMaxScaler归一化,再切训练测试集,代码能跑通、指标也好看,但测试集的极值信息已经通过 scaler 的最小最大值泄漏进训练过程了。正确的顺序是:先按时间切分,只用训练段拟合 scaler,再用同一个 scaler 去变换全量序列。
import pandas as pd import numpy as np from sklearn.preprocessing import MinMaxScaler data = pd.read_csv('forex_data.csv', index_col=0) values = data[['Price']].values # 保持二维,scaler 需要 (样本数, 特征数) time_step = 10 # 回看 10 天,预测第 11 天 split_idx = int(len(values) * 0.8) # 前 80% 训练,后 20% 测试,时序不能打乱 # 关键:fit 只碰训练段,transform 才作用于全量 scaler = MinMaxScaler(feature_range=(0, 1)) scaler.fit(values[:split_idx]) scaled = scaler.transform(values) def create_dataset(series, time_step): X, y = [], [] for i in range(len(series) - time_step): X.append(series[i:i + time_step, 0]) # 前 time_step 个点做输入 y.append(series[i + time_step, 0]) # 紧跟着的下一个点做标签 return np.array(X), np.array(y) X, y = create_dataset(scaled, time_step) # 样本 i 的标签落在原始序列第 i + time_step 个位置,据此判断样本归属 train_mask = np.arange(time_step, len(values)) < split_idx X_train, y_train = X[train_mask], y[train_mask] X_test, y_test = X[~train_mask], y[~train_mask] # Conv1D / LSTM 都要求 3D 输入:(样本数, 时间步, 特征数) X_train = X_train.reshape(-1, time_step, 1) X_test = X_test.reshape(-1, time_step, 1)create_dataset里那个i + time_step是标签位置,决定了划分训练测试时该按哪个下标切。用train_mask而不是简单的X[:train_size],是为了保证测试段的第一个样本也能拿到完整的 10 步历史——如果严格按位置切,测试集开头的 9 个样本会因为凑不齐窗口被丢掉,曲线在起点的对齐就会错位。
提示:
scaler.fit之后可以打印scaler.data_min_和scaler.data_max_,正常情况下这两个数应该等于训练段的最小值和最大值。如果发现等于全量序列的极值,说明 fit 的位置写错了。
2.4 流水线自检:四个跑模型之前必须过的检查点
X_train.shape应该是(n, 10, 1)三元组,第二维等于time_step,第三维等于特征数 1。写成(n, 10)的模型会在Conv1D那行直接报错。np.isnan(X_train).any()和np.isnan(y_train).any()都应为 False。随机游走不会产生缺失值,但真实行情接入后这一段必须补检查。- 反查一下
X_test[0]的最后一个值,它应该正好等于原始序列第split_idx个点的归一化结果。对不上说明窗口和切分点错位了。 - 训练段样本数和测试段样本数的比值应该接近 4:1。如果明显偏离,多半是
train_mask的长度和X对不齐。
3. 总体架构设计:因果卷积主干与 LSTM 对照组的并排搭建
3.1 感受野怎么算:窗口开多大,先看网络能看多远
TCN 能在长序列上占到便宜,核心就两个词:因果和膨胀。因果卷积在序列左侧补零,保证第 t 步的输出只依赖 t 及其之前的输入,不偷看未来;膨胀卷积把卷积核按固定间隔采样,第 i 层的膨胀率设为 d_i,感受野随层数指数级扩张。感受野的计算公式是RF = 1 + Σ(k_i - 1) × d_i,其中 k_i 是第 i 层的卷积核大小,d_i 是该层膨胀率。这个数决定了模型一次能看多远,也直接决定了窗口该开多大。
原示例里三层Conv1D的kernel_size都是 3、膨胀率全是 1,代进去就是RF = 1 + 2 + 2 + 2 = 7。窗口开的是 10,意味着最近 10 天里最早的那 3 天对输出没有任何贡献,等于白喂。这种不对齐在model.summary()里完全看不出来,只能靠手算。
| 结构 | kernel_size | 膨胀率 | 层数 | 感受野 |
|---|---|---|---|---|
| 原示例 | 3 | 1 / 1 / 1 | 3 | 7 |
| 加膨胀 | 3 | 1 / 2 / 4 | 3 | 15 |
| 再加一层 | 3 | 1 / 2 / 4 / 8 | 4 | 31 |
| 长窗口配置 | 3 | 1 / 2 / 4 / 8 / 16 | 5 | 63 |
我一般把窗口和感受野的比值控制在 1.0 到 1.5 之间:窗口 10 配 RF 15 刚好,窗口 30 就得继续往上加层。再大就没有意义了,多出来的层只是在拟合噪声,训练集 loss 会掉得很快而验证集纹丝不动。
3.2 TCN 主干实现:因果填充、残差连接与回归头的形状修正
下面是这份方案里 TCN 的实际构建代码。相比常见的简化写法,有三处改动:加膨胀率、加残差连接、把输出头从「保留时间维」改成「只取最后一步」。
import tensorflow as tf from tensorflow.keras.layers import Input, Conv1D, Dense, Dropout, Add, Lambda from tensorflow.keras.models import Model def build_tcn_model(input_shape, filters=64, kernel_size=3, dilations=(1, 2, 4)): inputs = Input(shape=input_shape) # (time_step, 1) x = inputs for d in dilations: # padding='causal':只在左侧补零,保证第 t 步看不到 t 之后的点 conv = Conv1D(filters=filters, kernel_size=kernel_size, padding='causal', dilation_rate=d, activation='relu')(x) conv = Dropout(0.2)(conv) # 时序模型很容易过拟合,这层不能省 # 残差连接:通道数不一致时先用 1x1 卷积投影对齐,再相加 if x.shape[-1] != filters: x = Conv1D(filters=filters, kernel_size=1, padding='same')(x) x = Add()([x, conv]) # 核心修正:只取最后一个时间步的表示来接回归头 x = Lambda(lambda t: t[:, -1, :], name='last_step')(x) outputs = Dense(1)(x) # 输出形状 (batch, 1) model = Model(inputs, outputs) model.compile(optimizer='adam', loss='mean_squared_error') return model tcn_model = build_tcn_model((time_step, 1)) tcn_model.summary()dilations=(1, 2, 4)对应上面表格里 RF=15 的那一档,膨胀率按 2 的幂次递增是对时序最自然的取法,间隔 1、2、4 步相当于同时盯着昨天、前天和大前天附近的信息。残差连接那段是这个结构里真正的工程价值:三层卷积堆叠之后原始输入的信号会被削弱,加上一跳直达的连接,梯度能沿短路径回传,训练稳定性明显不一样。最后的Lambda层是整个代码里最容易被忽略的一行,它把(batch, 10, 64)压成(batch, 64),再交给Dense输出标量。少了这一步,Dense(1)作用在时间维上会得到(batch, 10, 1),loss 还能算出来、还能往下降,但预测结果是一整段序列而不是一个点,到了评估环节才发现对不上。
3.3 LSTM 对照组:保持同一套输入输出约定
对照组的意义在于公平。两个模型吃同一份X_train、同一个y_train、同一种损失函数,唯一变化的只有网络结构,这样比出来的 RMSE 才有说服力。
from tensorflow.keras.layers import LSTM def build_lstm_model(input_shape, units=64): model = tf.keras.Sequential([ Input(shape=input_shape), # (time_step, 1),和 TCN 完全一致 LSTM(units), # return_sequences 默认 False,只输出最后一个隐状态 Dropout(0.2), Dense(1) # 输出形状同样是 (batch, 1) ]) model.compile(optimizer='adam', loss='mean_squared_error') return model lstm_model = build_lstm_model((time_step, 1))LSTM(units)默认return_sequences=False,输出的正是最后一个时间步的隐状态,形状(batch, units),这一点和 TCN 里Lambda取最后一步在语义上是对齐的——两者都是「把一段序列压成一个向量,再回归出一个数」。这一层对齐如果没做,比如 TCN 保留了时间维而 LSTM 没保留,对比就变成了两个不同任务的对比,答辩时被问到会很被动。
参数量上,units=64的单层 LSTM 大约有 1.7 万参数,filters=64的三层 TCN 大约 5 万参数。有人会拿这个说「TCN 参数量更大所以更强」,其实说反了——TCN 堆了三层、参数更多,却因为卷积可以整段并行计算,实际训练时间往往比同规模的 LSTM 短。
3.4 训练配置:验证集的切法比 epoch 数更关键
validation_split=0.1在时序任务里有一个容易忽略的细节:它是从训练集尾部切 10%,也就是取时间上最靠后的那段做验证。这在时序场景下反而是正确的做法,因为验证段的分布和测试段更接近。如果换成随机打乱再切,验证 loss 会显得特别漂亮,但那是未来数据参与了模型选择。
from tensorflow.keras.callbacks import EarlyStopping callbacks = [EarlyStopping(monitor='val_loss', patience=8, restore_best_weights=True)] history_tcn = tcn_model.fit( X_train, y_train, epochs=50, # 上限,实际通常 20~30 轮就被早停打断 batch_size=32, validation_split=0.1, # 从训练段尾部切,时序上不引入未来数据 callbacks=callbacks, verbose=1)patience=8的含义是验证 loss 连续 8 轮不下降就停手,restore_best_weights=True保证拿回来的是验证集上最好的那次权重而不是最后一轮的。这个参数在调参阶段能省下大量时间,尤其是 TCN 这类模型在第 5 轮左右就开始过拟合了,硬跑满 50 轮只会让结果更差。
4. 训练、预测与结果评估:反归一化顺序与 RMSE 口径的对齐
4.1 训练过程观测:两条 loss 曲线要一起看
训练跑起来之后第一件事不是看预测图,是看history里的loss和val_loss。两条曲线同步下降且间距稳定,说明模型容量和数据匹配;训练 loss 一路下探而验证 loss 在第 10 轮左右掉头向上,就是典型的过拟合,得靠加 Dropout、减层数或者提早停手。如果两条曲线都平得像一条直线,那往往不是模型的问题,而是数据本身没什么可学的——随机游走的下一日价格本质上不可预测,能学到的只有均值和方差。
这个预期得提前建立起来。用随机游走数据做基准时,一个合格的模型做到「接近随机猜测」的水平就已经算正常,后面看的是 TCN 和 LSTM 之间谁更贴近真实曲线,而不是谁把 RMSE 压到了零。真实行情接入后,这个心理预期要重新校准。
4.2 反归一化与 RMSE:先 reshape 再 inverse,顺序错了结果全废
归一化空间里算出来的 RMSE 小数点位很漂亮,但没有任何物理意义——0.03 到底是一天波动 0.03 块钱还是一天波动 3 个点,只有反变换回原始量纲才能说清楚。
from sklearn.metrics import mean_squared_error tcn_pred_scaled = tcn_model.predict(X_test) # 形状 (n, 1) lstm_pred_scaled = lstm_model.predict(X_test) # y_test 是一维的,inverse_transform 要求二维,必须先 reshape y_true = scaler.inverse_transform(y_test.reshape(-1, 1)) tcn_pred = scaler.inverse_transform(tcn_pred_scaled) lstm_pred = scaler.inverse_transform(lstm_pred_scaled) tcn_rmse = np.sqrt(mean_squared_error(y_true, tcn_pred)) lstm_rmse = np.sqrt(mean_squared_error(y_true, lstm_pred)) print(f'TCN RMSE: {tcn_rmse:.4f}') print(f'LSTM RMSE: {lstm_rmse:.4f}')y_test那一行最容易翻车。它从create_dataset出来是一维数组,而inverse_transform是对着二维数据拟合的,直接扔进去会按行反变换,得到的是一个形状完全对不上的结果,而程序不一定报错,只是数值全错。.reshape(-1, 1)这一步没有商量的余地,三个数组的反变换顺序也必须是先真值后预测、各自独立调用,不能图省事在同一行里串着写。
4.3 三条曲线叠一张图:可视化脚本与解读方式
数字之后才是图。一张对比图里至少要压三条线:真实价格、TCN 预测、LSTM 预测,这样走势上的分歧一眼就能看出来。
import matplotlib.pyplot as plt # 中文字体与负号显示,不设这两行标签会变成方块 plt.rcParams['font.sans-serif'] = ['SimHei'] plt.rcParams['axes.unicode_minus'] = False plt.figure(figsize=(14, 7)) plt.plot(y_true, label='真实价格', color='blue', linewidth=1.2) plt.plot(tcn_pred, label='TCN 预测', color='orange', linewidth=1.2) plt.plot(lstm_pred, label='LSTM 预测', color='green', linewidth=1.2, alpha=0.8) plt.title('时间序列预测对比:TCN vs LSTM') plt.xlabel('时间步') plt.ylabel('价格') plt.legend() plt.grid(alpha=0.3) plt.tight_layout() plt.savefig('prediction_compare.png', dpi=150)linewidth调到 1.2 是为了三条线叠在一起时不糊成一片,alpha=0.8让最上面那条线透出一点底下的,方便判断重合程度;tight_layout()负责把被裁掉的标签拉回来,savefig的dpi=150是投屏和打印都够用的分辨率。
4.4 结果怎么读:三个不要踩的解读陷阱
- 只看整段 RMSE。整段平均值会被前面一大段平稳区间稀释,真正体现差异的是后 1/3 段。我一般会额外算一个后半段的 RMSE,两个数一起报。
- 把「少偏移一点」说成「精度更高」。在随机游走数据上,任何模型的预测都会退化成接近一个常数,差别只在贴近均值的程度。这种比较能说明模型的稳定性,但说明不了预测能力。
- 单次结果当结论。随机种子换一个,两个模型的排名可能就翻过来了。稳妥的做法是跑 5 个种子取均值,报「均值 ± 标准差」,而不是报一次跑出来的最好那个数。
注意:如果 TCN 的预测曲线整体比真实值滞后一个时间步,先别急着改网络,检查一下
create_dataset里的y取的是不是series[i + time_step]。取成series[i + time_step - 1]就会出现这种整体位移。
5. 常见问题排查:时序预测里最容易翻车的五个点
5.1 形状与感受野:三个直接让结果作废的建模坑
坑一:Dense(1)直接接Conv1D的输出,loss 能降但预测是一整段。现象是训练过程一切正常,val_loss稳步下降,但拿predict出来的结果去算 RMSE 时报形状不匹配,或者画出来的「预测」是一条密密麻麻的带状物。原因在于Conv1D输出形状是(batch, time_step, filters),Dense(1)会作用在最后一维上,得到(batch, time_step, 1)。如果标签是一维的,广播机制会把标签在时间维上重复,loss 仍然能算出来但学的是另一个目标。解决方式是显式地取最后一个时间步,用Lambda(lambda t: t[:, -1, :]),或者接GlobalAveragePooling1D()把时间维压掉。前者保留末步语义,后者做平均池化,时序预测一般选前者。
坑二:感受野小于窗口,早期输入完全浪费。现象是模型怎么调都没起色,加大层数、加宽通道都没有明显收益。原因在 3.1 节算过:三层kernel_size=3、膨胀率全 1 的结构,RF 只有 7,窗口开到 20 的话后面 13 天里只有最近 7 天真正起作用,前面那段纯属占位。解决方式是按RF = 1 + Σ(k_i - 1) × d_i手算一遍,把窗口收缩到 RF 以内,或者把膨胀率改成 1/2/4 把 RF 撑到 15 以上。这个检查建议在写完模型结构的第一时间就做,别等训练三轮之后才想起来。
坑三:padding='same'混进因果卷积里,未来信息偷偷漏进来。现象是验证集和测试集的指标好得反常,画出来的预测曲线几乎贴着真实值走,但换一段新数据立刻崩掉。原因是padding='same'会在序列两侧同时补零,输出第 t 步就看到了第 t+1 步的输入,等于提前知道了答案。解决方式很直接:全文搜一遍padding参数,凡是时序主干上的卷积一律改成'causal';另外检查一下LSTM有没有被Bidirectional包起来,双向结构在时序预测里是同等性质的泄漏。
5.2 数据切分与指标口径:两个自欺欺人的评估坑
坑四:scaler在全量数据上fit,测试集信息提前进了训练。现象是模型在测试集上的 RMSE 明显低于预期,换个随机种子重新划分数据集,指标波动很大。原因是MinMaxScaler的极值来自全量序列,测试段的最高价和最低价在归一化阶段就已经影响到了训练样本的数值范围。虽然损失函数并不知道哪个样本属于测试集,但缩放的边界信息确实跨过了切分线。解决方式是先按时间切,fit只碰训练段,transform再作用于全量。这个改动只多两行代码,但对结论的可信度影响是数量级的。
坑五:RMSE 在归一化空间里算,数值好看但没有物理意义。现象是报告里写「RMSE 为 0.028」,被问到「这个 0.028 是多少钱」时答不上来。原因在于归一化把价格压缩到了 0 到 1 之间,误差自然也是在那个尺度上,和真实的价格单位差了上百倍。解决方式是先inverse_transform回原始量纲,再算 RMSE。同时建议把 MAE 一起报出来——RMSE 对异常值敏感,MAE 更稳健,两个数放在一起能看出误差的分布形态,而不是被少数几个大偏差拉偏。
顺带一条容易被忽略的:单次运行的结论不可靠。换了种子排名就翻转,说明两个模型的差距在噪声范围内。跑 5 个种子取均值和标准差,报告里写「TCN 的 RMSE 为 A±B,LSTM 为 C±D」,比只报一个最漂亮的数字经得起追问得多。
6. 验收与答辩:一份能当场跑通的检查清单
答辩现场最怕的不是模型效果一般,而是被要求「现在跑一遍」的时候环境起不来、数据对不上。我一般会在交付前把下面这张表从头过一遍,每一项都能在两分钟内验证。
| 检查项 | 怎么查 | 合格线 |
|---|---|---|
| 数据可复现 | 换一台机器,只装requirements里列的库,从头跑 | 输出曲线与上一次逐点一致 |
| 张量形状 | model.summary()看首尾两行 | 输入(None, 10, 1),输出(None, 1) |
| 感受野 | 按RF = 1 + Σ(k-1)d手算 | RF ≥time_step |
| 因果性 | 全文搜padding='same'、Bidirectional | 时序主干上零命中 |
| 归一化顺序 | 看scaler.fit的入参 | 只传训练段 |
| 指标口径 | 打印 RMSE 时输出单位 | 与原始价格同单位 |
| 对比公平 | 两个模型传入的X_train是不是同一个对象 | 完全一致 |
| 可视化 | 三线对比图 | 后期段没有整体平移 |
这张表里最值得花时间的是「因果性」和「归一化顺序」两项,因为它们不会让程序报错,只会让结论悄悄失真。前者决定了模型是不是在作弊,后者决定了指标是不是在注水,两个问题都不会自己暴露出来。
讲的时候顺序也有讲究。先讲需求边界——预测什么、窗口多长、指标是什么,把听的人的预期锚定住;再讲结构选择——为什么用因果卷积而不是普通卷积、为什么窗口和感受野要对齐,这一段是体现工程判断力的地方;最后才放对比结果。如果 TCN 的 RMSE 只是略优于 LSTM,不要硬吹,老老实实说「在长窗口下 TCN 的滞后更小,两者差距在 5% 以内,考虑到 TCN 训练时间更短,综合性价比更高」,这种说法比「TCN 全面碾压」可信得多。真被问到答不上来的细节,直接说「这部分我还没验证」,也比现场编一个解释强。
从那以后我每次交时序模型的方案,都强制自己先手算一遍感受野、再确认一遍scaler.fit的入参,然后才去调超参数。顺序反过来的话,前面所有调参工作都有可能建立在一个错误的基线上。希望帮到你。
本文还有配套的精品资源,点击获取