☰
Python+TensorFlow实战:TCN时间序列预测与LSTM对比
2026/11/4 3:12:15 网站建设 项目流程

简介:面向初级及以上数据科学家、机器学习爱好者与时间序列分析从业者的实战型项目文档,聚焦用Python构建时间卷积神经网络(TCN)完成时间序列预测,并以金融市场趋势分析为典型场景,帮助读者理解TCN相较RNN在长序列建模上的精度优势。资源包共1个docx文件,约31KB,内容涵盖模型原理说明、环境准备、数据预处理、TCN与LSTM对比实现及预测结果可视化等完整环节,配有大量示例代码与执行截图,便于对照复现与调参实验。已有174人学习,适合希望从理论到落地系统掌握TCN预测流程、并借助两种网络效果对比图加深理解的读者参考。

1. 从一份“跑不动”的预测脚本说起

去年帮一个做量化研究的朋友看代码,他丢过来一个时间序列预测的脚本,说 LSTM 训到第 30 个 epoch 就开始发散,调了两天学习率也没救回来。我打开一看,输入窗口拉到 120 步,单层 LSTM 硬扛,后面接个 Dense 直接回归价格——典型的“用 RNN 处理长序列”翻车现场。后来换成 TCN(Temporal Convolutional Network,时间卷积网络),同样的数据、同样的窗口长度,训练曲线稳得多,验证集 RMSE 直接降了一截。这份 Python + TensorFlow 的 TCN 时间序列预测项目实例,讲的就是这件事:用因果卷积加膨胀卷积搭一个能看长程依赖的预测模型,再拿 LSTM 做对照,把数据生成、预处理、建模、评估、可视化整条链路跑通。适合刚接触时间序列预测、想找一个能直接跑起来的完整工程骨架的开发者,也适合手里有 RNN 模型想换架构试试的熟手。

2. 项目骨架与技术选型:为什么是 TCN 而不是继续堆 LSTM

2.1 技术栈清单与各自职责

这个项目的依赖不多,但每一个都卡在关键位置上,缺一个整条链路就断。先把它们摆清楚:

库版本建议在本项目中的职责
numpy>=1.21生成模拟价格序列、构造滑动窗口样本、数组 reshape
pandas>=1.3日期索引、CSV 读写、DataFrame 组织
scikit-learn>=1.0MinMaxScaler 归一化、train/test 切分辅助、RMSE 计算
tensorflow>=2.8搭建 TCN 与 LSTM 模型、训练、预测
matplotlib>=3.4真实值 vs 预测值对比曲线

选 TensorFlow 而不是 PyTorch,原因很实际:项目里 TCN 的核心就是Conv1D(padding='causal'),Keras 的函数式 API 把这个参数直接暴露出来,三行就能定义一个因果卷积层,不用自己写 padding 逻辑。如果你习惯 PyTorch,把Conv1d的padding手动补零也能等价实现,但这份代码的骨架是按 Keras 写的,照抄最省事。

提示:TensorFlow 2.8 以上版本对padding='causal'的支持是稳定的,低于 2.4 的版本在部分平台上会有因果卷积对齐的 bug,装环境时留意一下。

2.2 目录结构与数据流走向

项目本身不复杂,但数据从生成到出图的流向值得先理一遍,后面看代码才不会迷路。我一般会把它组织成这样的结构:

tcn-forecast/ ├── data/ │ └── forex_data.csv # 模拟生成的价格序列 ├── src/ │ ├── generate_data.py # 造数据 │ ├── preprocess.py # 归一化 + 滑窗 + 切分 │ ├── models.py # TCN 与 LSTM 两个 build 函数 │ ├── train_eval.py # 训练、预测、算 RMSE │ └── plot_result.py # 三条曲线画一张图 └── requirements.txt

数据流是这样的:generate_data.py用随机游走造出 1000 天的价格,落成 CSV;preprocess.py读进来做 MinMax 归一化,再用滑动窗口切成[样本数, time_step, 1]的三维张量;models.py里两个 build 函数分别吐出 TCN 和 LSTM;train_eval.py各训 50 轮,反归一化后算 RMSE;最后plot_result.py把真实价格、TCN 预测、LSTM 预测画在同一张图上。整条链路没有隐藏状态,每一步的输入输出都能对上,这也是我推荐拿它当模板的原因——改起来知道该动哪一块。

2.3 TCN 的两个关键机制:因果卷积与膨胀卷积

很多人第一次看 TCN 会疑惑:卷积不是用来处理图像的么,怎么跑到序列上了?关键在两点。第一是因果卷积(causal convolution),padding='causal'保证第 t 个输出只依赖 t 及之前的输入,不会“偷看”未来,这一点和 RNN 的时序约束一致。第二是膨胀卷积(dilated convolution),卷积核不是挨着挨着扫,而是每隔 d 个点取一个,d 逐层翻倍。三层膨胀卷积、kernel_size=3 的情况下,感受野能覆盖到 1 + 2×(3-1)×(1+2+4) 这个量级,几十步的历史信息一层层叠上来,比单层 LSTM 的记忆窗口更可控。

项目正文里的 TCN 实现其实只用了因果卷积,没显式写膨胀率,这是这份代码的一个简化点。我在实际改造时会补上dilation_rate参数,后面第 5 章会讲怎么加。先记住结论:因果卷积解决“不能看未来”,膨胀卷积解决“看得够远”,两者合起来才是完整 TCN 的骨架。

3. 数据准备与预处理:从随机游走到三维张量

3.1 造一份能复现的模拟价格序列

真实外汇数据涉及下载和清洗,项目为了聚焦模型本身,用随机游走生成模拟数据。这段代码看着简单,但随机种子的位置很关键:

import numpy as np import pandas as pd # 固定随机种子,保证每次生成的数据完全一致,方便复现对比结果 np.random.seed(42) dates = pd.date_range(start='2020-01-01', periods=1000, freq='D') # 正态分布增量累加,模拟价格随机游走;loc=1.0 是日均漂移,scale=0.01 是波动率 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')

np.random.seed(42)必须放在normal调用之前,否则每次跑出来的序列都不一样,TCN 和 LSTM 的 RMSE 对比就失去意义了。loc=1.0让价格有轻微上行趋势,scale=0.01控制日波动幅度,cumsum()把增量累加成价格路径,最后加 100 是给一个合理的起始价位。这套参数造出来的序列既有趋势又有噪声,拿来验证模型够用了。换成真实数据时,只要保证 CSV 里有一列按时间排序的价格,后面的预处理代码一行不用改。

3.2 归一化与滑动窗口构造样本

价格序列直接喂给神经网络是大忌,量纲差太大会让梯度更新失衡。这里用 MinMaxScaler 压到 [0,1],再用滑动窗口切样本:

import pandas as pd import numpy as np from sklearn.preprocessing import MinMaxScaler data = pd.read_csv('forex_data.csv', index_col=0) scaler = MinMaxScaler() # fit_transform 在训练数据上拟合,注意 scaler 要留着后面反归一化用 scaled_data = scaler.fit_transform(data) def create_dataset(data, time_step=1): X, y = [], [] for i in range(len(data) - time_step): # 前 time_step 个点作为输入特征 X.append(data[i:(i + time_step), 0]) # 第 time_step+1 个点作为预测目标 y.append(data[i + time_step, 0]) return np.array(X), np.array(y) time_step = 10 X, y = create_dataset(scaled_data, time_step) train_size = int(len(X) * 0.8) X_train, X_test = X[:train_size], X[train_size:] y_train, y_test = y[:train_size], y[train_size:] # Conv1D 要求输入是 [样本数, 时间步, 特征数],这里特征数=1 X_train = X_train.reshape(X_train.shape[0], X_train.shape[1], 1) X_test = X_test.reshape(X_test.shape[0], X_test.shape[1], 1)

create_dataset的逻辑是“用过去 10 天预测第 11 天”,time_step=10就是窗口长度。这个参数直接决定模型能看到多长的历史,调大能捕捉更长趋势,但样本数会减少、训练变慢。reshape那一步是新手最容易漏的:Keras 的Conv1D和LSTM都要求三维输入,二维数组直接喂进去会报 shape 不匹配。另外注意scaler是在全量数据上fit_transform的,严格来说存在轻微的数据泄漏,工程上更稳妥的做法是只在训练段拟合、测试段transform,这一点在第 4 章的避坑里会展开。

3.3 训练集与测试集的切分边界

切分用的是最简单的按时间顺序 8:2 划分,X[:train_size]在前、X[train_size:]在后。时间序列不能随机打乱切分,否则模型会用“未来”的样本训练、用“过去”的样本测试,评估结果虚高。这份代码的顺序切分是对的,但有个细节值得注意:train_size是按样本数算的,不是按原始时间点数,因为滑窗之后样本数比原始数据少了time_step个。如果你把time_step从 10 改到 60,训练样本会相应减少,切分点也跟着变,这一点在调参时要心里有数。

4. 模型搭建与训练:TCN 与 LSTM 的对照实验

4.1 TCN 模型的三层因果卷积堆叠

TCN 的 build 函数是整份代码的核心,三层Conv1D加 Dropout,最后接一个 Dense 输出单值:

import tensorflow as tf from tensorflow.keras.layers import Input, Conv1D, Dense, Dropout from tensorflow.keras.models import Model def build_tcn_model(input_shape): inputs = Input(shape=input_shape) # 第一层因果卷积:padding='causal' 保证只看当前及历史 x = Conv1D(filters=64, kernel_size=3, padding='causal', activation='relu')(inputs) x = Dropout(0.2)(x) # 第二层,通道数不变,继续提取时序特征 x = Conv1D(filters=64, kernel_size=3, padding='causal', activation='relu')(x) x = Dropout(0.2)(x) # 第三层,输出仍是序列,交给 Dense 逐时间步映射 x = Conv1D(filters=64, kernel_size=3, padding='causal', activation='relu')(x) outputs = Dense(1)(x) model = Model(inputs, outputs) model.compile(optimizer='adam', loss='mean_squared_error') return model tcn_model = build_tcn_model((X_train.shape[1], 1)) tcn_model.summary() tcn_model.fit(X_train, y_train, epochs=50, batch_size=32, validation_split=0.1)

filters=64是每层的输出通道数,kernel_size=3是卷积核宽度,padding='causal'是因果约束的关键。三层堆叠之后,Dense(1)作用在最后一个时间步的 64 维向量上,输出一个预测值。这里有个容易忽略的点:Dense是作用在整个序列上的,输出 shape 是[batch, time_step, 1],而y_train是[batch],Keras 会自动对齐取最后一个时间步,但如果你后面要改多步预测,这里得显式加Lambda(lambda t: t[:, -1, :])把最后一步切出来。validation_split=0.1从训练集里再切 10% 做验证,用来观察过拟合,不参与梯度更新。

4.2 LSTM 对照模型的搭建

对照组用单层 LSTM,结构尽量简洁,保证对比的是架构差异而不是参数量差异:

from tensorflow.keras.layers import LSTM def build_rnn_model(input_shape): model = tf.keras.Sequential() # 64 个隐藏单元,与 TCN 的 filters 对齐,控制变量 model.add(LSTM(64, input_shape=input_shape)) model.add(Dropout(0.2)) model.add(Dense(1)) model.compile(optimizer='adam', loss='mean_squared_error') return model rnn_model = build_rnn_model((X_train.shape[1], 1)) rnn_model.summary() rnn_model.fit(X_train, y_train, epochs=50, batch_size=32, validation_split=0.1)

LSTM 的 64 个隐藏单元和 TCN 的 64 个 filters 对齐,是为了让两个模型的容量大致可比。LSTM(64)默认只返回最后一个时间步的隐藏状态,所以后面直接接Dense(1)就行,不需要像 TCN 那样处理序列输出。训练配置完全一致:同样的优化器、同样的 loss、同样的 epoch 和 batch_size,这样 RMSE 的差异才能归因到架构本身。

4.3 预测、反归一化与 RMSE 评估

训练完两个模型,预测结果要先反归一化回原始价格量纲,再算误差:

from sklearn.metrics import mean_squared_error # 预测出来的是 [0,1] 区间的值,必须反归一化才能和真实价格比 tcn_predictions = tcn_model.predict(X_test) tcn_predictions = scaler.inverse_transform(tcn_predictions) rnn_predictions = rnn_model.predict(X_test) rnn_predictions = scaler.inverse_transform(rnn_predictions) # y_test 也要反归一化,注意 reshape 成二维才能喂给 scaler y_test_inv = scaler.inverse_transform(y_test.reshape(-1, 1)) tcn_rmse = np.sqrt(mean_squared_error(y_test_inv, tcn_predictions)) rnn_rmse = np.sqrt(mean_squared_error(y_test_inv, rnn_predictions)) print(f'TCN RMSE: {tcn_rmse}') print(f'RNN RMSE: {rnn_rmse}')

inverse_transform这一步是血泪经验:忘了反归一化,算出来的 RMSE 是 0.0x 量级,看着特别漂亮,但那是归一化空间里的误差,跟真实价格对不上,汇报出去就是事故。y_test.reshape(-1, 1)也不能省,MinMaxScaler要求二维输入,一维数组直接传会报错。RMSE 本身对异常值敏感,如果测试段刚好有一段剧烈波动,两个模型的 RMSE 都会偏高,这时候可以补一个 MAE 一起看,判断更稳。

4.4 三条曲线的可视化对比

最后把真实价格、TCN 预测、LSTM 预测画在一张图上,直观看出差距:

import matplotlib.pyplot as plt plt.figure(figsize=(14, 7)) plt.plot(y_test_inv, label='真实价格', color='blue') plt.plot(tcn_predictions, label='TCN 预测', color='orange') plt.plot(rnn_predictions, label='RNN 预测', color='green') plt.title('外汇时间序列预测') plt.xlabel('时间步') plt.ylabel('价格') plt.legend() plt.show()

figsize=(14, 7)是为了让 200 个测试点的曲线不至于挤成一团。三条线叠在一起看,TCN 通常在趋势转折处跟得更紧,LSTM 在长窗口下容易出现滞后或 overshoot。这张图不只是展示用,调参时反复看它能发现很多数字指标看不出来的问题,比如预测整体偏移、某一段系统性高估。

5. 避坑与排查:五个真实踩过的坑

5.1 归一化在测试集上泄漏

现象:模型在测试集上 RMSE 异常低,换一批数据就崩。原因:scaler.fit_transform在全量数据上做,测试集的 min/max 信息提前进入了归一化参数,等于模型“见过”测试数据的分布。解决:只在训练段fit,测试段用同一个 scalertransform:

scaler.fit(scaled_data[:train_size]) train_scaled = scaler.transform(scaled_data[:train_size]) test_scaled = scaler.transform(scaled_data[train_size:])

5.2 因果卷积的 padding 写错方向

现象:模型预测精度高得离谱,但上线后完全不能用。原因:把padding='causal'写成了padding='same',卷积核对称补零,第 t 个输出用到了 t+1、t+2 的输入,等于偷看了未来。解决:TCN 里所有Conv1D必须显式写padding='causal',改完用一个小样本手动验证:把输入最后一位改掉,看前面的输出是否变化,变了就说明泄漏了。

5.3 输入 shape 不匹配报错

现象:ValueError: Input 0 of layer conv1d is incompatible with the layer: expected ndim=3, found ndim=2。原因:create_dataset返回的是二维数组,忘了reshape成[样本数, 时间步, 1]。解决:在喂模型之前统一 reshape,并在build_tcn_model里用input_shape=(time_step, 1)明确声明,让报错提前到建模阶段而不是训练阶段。

5.4 反归一化漏做导致指标失真

现象:RMSE 打印出来是 0.02,但画图发现预测曲线和真实曲线差了一大截。原因:预测值没做inverse_transform,在 [0,1] 空间里算误差,数值自然小。解决:预测值和真实值都反归一化后再算指标,养成“先反归一化、再评估”的固定顺序,别图省事跳过。

5.5 随机种子位置不对导致结果不可复现

现象:同样的代码跑两次,RMSE 差 20%。原因:np.random.seed只固定了数据生成,没固定 TensorFlow 的权重初始化,每次训练起点不同。解决:在训练脚本开头补上tf.random.set_seed(42),数据种子和模型种子分开设,两个都要固定,对比实验才有意义。

6. 二次开发:把简化版 TCN 补成能上生产的骨架

这份代码的 TCN 只用了因果卷积,没写膨胀率,感受野其实有限。我一般会先补上膨胀卷积,让模型真正具备捕捉长程依赖的能力:

def build_tcn_model_v2(input_shape, dilations=(1, 2, 4)): inputs = Input(shape=input_shape) x = inputs for d in dilations: # dilation_rate 逐层翻倍,感受野指数级扩大 x = Conv1D(filters=64, kernel_size=3, padding='causal', dilation_rate=d, activation='relu')(x) x = Dropout(0.2)(x) # 只取最后一个时间步,避免 Dense 作用在整个序列上 x = tf.keras.layers.Lambda(lambda t: t[:, -1, :])(x) outputs = Dense(1)(x) model = Model(inputs, outputs) model.compile(optimizer='adam', loss='mean_squared_error') return model

dilations=(1, 2, 4)三层下来,感受野从原来的 7 步扩到 29 步,time_step=10的窗口能被完整覆盖。Lambda那一步是把序列输出切成最后一步,这样Dense(1)的输出 shape 和标签严格对齐,不用依赖 Keras 的隐式对齐。改完这个,再想进一步就是加残差连接(x = Add()([x, shortcut]))和 LayerNorm,那是完整 TCN 的标准配置,但会引入更多超参,建议先把膨胀卷积跑通、确认 RMSE 有改善再往上叠。

验证改造是否有效,我习惯用固定种子跑三组对比:原始 TCN、加膨胀的 TCN、LSTM,每组跑三次取 RMSE 均值。只看单次结果容易被随机初始化带偏,三次均值能过滤掉大部分噪声。如果加膨胀后 RMSE 反而升了,先检查dilation_rate和time_step是否匹配——窗口只有 10 步、膨胀率却设到 8,大部分卷积核会落在 padding 上,等于白算。

还有一个改造方向是换真实数据。把generate_data.py换成读 CSV 的 loader,保证列名和索引对齐,预处理代码一行不用动。真实外汇数据有缺失值和周末跳空,记得在归一化前补一个ffill加异常值裁剪,不然MinMaxScaler会被极端值拉偏,整个 [0,1] 区间被压缩到很窄一段,模型学不到东西。

从那以后我每次搭时序模型,都强制先跑一遍“因果性验证”——改输入最后一位、看前面输出变不变,再开始调参。这个习惯帮我挡掉过两次上线前的隐性泄漏,比任何指标都靠谱。希望帮到你。

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

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

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

立即咨询