ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

TCN外汇预测:比LSTM更稳的时间序列模型落地方案

TCN外汇预测:比LSTM更稳的时间序列模型落地方案

简介:这是一份基于Python和TensorFlow实现时间卷积神经网络(TCN)进行时间序列预测的完整项目实例,面向初级及以上水平的数据科学家、机器学习爱好者,以及需要处理金融行情等长期序列分析任务的专业人士。资源共1个文件,为docx格式的Word文档,压缩包约31KB。文档内除TCN原理讲解外,还完整给出了环境配置、模拟数据生成、数据预处理、TCN与RNN模型搭建与训练代码,并配有执行结果截图和两类模型的预测效果直观对比图,可按步骤对照复现。已有171人学习,案例以外汇市场模拟数据为例,清晰展示了因果卷积与膨胀卷积如何捕捉长程依赖、缓解RNN梯度消失问题,帮助读者理解TCN在复杂序列预测中的精度优势。借助该文档,读者可快速掌握从数据准备、模型训练到结果可视化的全流程,并借助参数调整建议迁移至自己的时间序列预测项目。

1. 用 TCN 做外汇序列预测:一个比 RNN 更稳的落地方案

时间序列预测一直是机器学习里有点“玄学”味道的方向,尤其是碰到金融数据这种信噪比极低、走势又带明显长程依赖的场景。这个项目给出了一个非常直接的结论:用时间卷积神经网络(Temporal Convolutional Network,TCN)做外汇价格预测,在后期走势的拟合上明显优于传统的 LSTM 模型。项目从数据生成、预处理、模型搭建到对比评估全部跑通,适合两类人——一类是刚开始接触时序预测、想找个完整代码模板照着跑的初学者,另一类是在 RNN 系模型里反复调参却迟迟提不了精度、想换个思路的熟手。

整套实现基于 TensorFlow 与 Python,核心就两件事:第一,用因果卷积加膨胀卷积捕获序列的长程依赖,避开 RNN 在长输入序列下的梯度消失问题;第二,用同一份外汇模拟数据、同样的训练参数,跑 TCN 和 RNN 两个模型对比 RMSE。下面我们从数据侧开始,把整个项目一步步拆开。

2. 项目结构与数据侧准备:从模拟数据到可训练的样本集

2.1 环境选型与依赖清单

项目基于 TensorFlow 框架实现,深度学习部分用到 Keras 的函数式 API 和 Sequential API。基础依赖集中在以下这些库:

pip install numpy pandas tensorflow matplotlib scikit-learn

版本方面不需要特别苛刻,TensorFlow 2.x 都能跑通。numpy 用于数值计算与模拟数据生成,pandas 负责时间序列数据的 DataFrame 管理,scikit-learn 提供 MinMaxScaler 归一化和 train_test_split 相关能力,matplotlib 做预测结果的可视化。需要注意的是,这里没有用到任何自定义 CUDA 配置,CPU 环境也能完成训练,只是时间稍长。

2.2 模拟外汇数据的生成逻辑

项目没有直接要求读者去下载真实外汇历史数据,而是先用随机种子生成了一段模拟价格序列,这样保证任何人在任何机器上跑出来的数据完全一致,复现不会翻车。

import numpy as np import pandas as pd # 设置随机种子,保证每次运行生成的数据一致 np.random.seed(42) # 生成 1000 个交易日的模拟价格数据 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')

这段代码的核心逻辑是np.random.normal(loc=1.0, scale=0.01, size=len(dates)).cumsum() + 100。先以 1.0 为均值、0.01 为标准差生成随机游走的增量序列,再做累加得到价格曲线,最后加上 100 的基准价格。这样生成的序列具有明显的趋势性和随机性,和真实外汇价格的对数收益特征比较接近。

参数说明:loc控制每天的期望收益,scale控制波动幅度,periods=1000决定了样本总长度。如果想改成真实数据,只需要把pd.read_csv('forex_data.csv', index_col=0)里的路径换成自己下载的 CSV 文件,并保证索引列是日期、数据列是价格即可。

2.3 滑动窗口切分:从一维序列到[样本数, 时间步, 特征数]的三维张量

拿到价格序列之后,不能直接塞给卷积网络。TCN 和 LSTM 的输入都要求三维张量,形状为[样本数, 时间步, 特征数]。因此需要按滑动窗口的方式,把一维序列切成多个定长的输入片段和对应的预测目标。

from sklearn.preprocessing import MinMaxScaler # 加载原始数据 data = pd.read_csv('forex_data.csv', index_col=0) # 做 min-max 归一化,将价格压缩到 [0, 1] 区间 scaler = MinMaxScaler() scaled_data = scaler.fit_transform(data) def create_dataset(data, time_step=1): X, y = [], [] for i in range(len(data) - time_step): X.append(data[i:(i + time_step), 0]) y.append(data[i + time_step, 0]) return np.array(X), np.array(y) # 设置时间步为 10,即用前 10 个交易日预测下 1 个交易日 time_step = 10 X, y = create_dataset(scaled_data, time_step) # 按 8:2 划分训练集和测试集 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:] # 将输入 reshape 为 [样本数, 时间步, 特征数] 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函数的实现方式:对第i个样本,取data[i:i+time_step]这段长度为time_step的窗口作为模型输入,取data[i+time_step]作为该窗口对应的预测标签。窗口逐个向后滑动,每滑动一步就生成一个新样本。

time_step=10意味着模型只能看到最近 10 个交易日的价格走势,然后输出下一个交易日的预测值。这个超参数直接影响模型能捕获的依赖长度——调大time_step会扩大感受野,但也增加了训练难度。归一化放在切分之前,是因为 MinMaxScaler 需要在整个数据范围内计算最小值和最大值,如果先切分再归一化,训练集和测试集会各自计算缩放参数,导致数据分布不一致,预测结果就不具备可比性了。

3. 核心模块拆解:TCN 模型结构、因果卷积与训练流程

3.1 用 Keras 函数式 API 搭建 TCN 主干

TCN 的模型构建是本项目的核心部分,直接决定了最终预测效果。项目里采用的是 Keras 函数式 API 而非 Sequential API,主要原因是后续如果需要给 TCN 层添加残差连接或自定义膨胀系数,函数式写法更灵活。

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) # 第三层因果卷积 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()

逐层拆解一下这段代码。Input(shape=input_shape)接收的是[时间步=10, 特征数=1]的输入张量。之后的Conv1D有三个关键参数:filters=64表示这一层有 64 个卷积核,每个卷积核会提取出一种独立的时序模式;kernel_size=3表示每次卷积覆盖 3 个连续时间步;padding='causal'是 TCN 与普通 CNN 最本质的区别——普通 CNN 的卷积核会同时看到窗口内未来的数据,而 causal padding 会在序列左侧补零,确保第t时刻的输出只依赖于t时刻及之前的信息,不会造成信息泄露。

Dropout(0.2)的作用是缓解过拟合,在训练时随机丢弃 20% 的神经元输出。连续堆叠 3 层 Conv1D 是为了增加网络的感受野——对于 kernel_size=3 的一维卷积,堆叠 3 层后感受野为 7 个时间步,加上后续如果使用膨胀卷积,理论上可以覆盖更长历史。输出层用Dense(1)将 64 维的特征向量压缩成单个预测价格,激活函数为默认的线性激活,因为回归任务不需要限制输出范围。

这里需要说明一下:项目为了保持代码简洁,没有加入膨胀卷积(dilated convolution)参数。实际完整的 TCN 会在 Conv1D 中增加dilation_rate参数,例如dilation_rate=2、dilation_rate=4,指数级扩大感受野。后面“二次开发”章节我会给出具体的改造方式。

3.2 训练流程与损失函数选择

tcn_model.fit( X_train, y_train, epochs=50, batch_size=32, validation_split=0.1 )

epochs=50表示整个训练集完整过 50 遍,batch_size=32是每轮更新权重的样本数。validation_split=0.1表示从训练集尾部切出 10% 的数据作为验证集,每个 epoch 结束后计算一次验证集损失,用于监控是否过拟合。

损失函数用的是mean_squared_error(MSE),这是因为本项目评估指标是 RMSE(均方根误差),MSE 比 MAE 对较大误差更敏感,能够更清晰地区分模型优劣。优化器选择 Adam,默认学习率 0.001 在这个数据规模和任务上表现稳定,通常不需要手动调整。

一个容易被忽略的细节是tcn_model.fit传入的X_train形状是[788, 10, 1](800 个训练窗口减去 10 个时间步损失再乘 80%),这里的1代表特征维度。如果后续想加入更多特征,比如加入交易量、波动率等技术指标,只需要把 reshape 时的最后一个维度从1改成特征总数,模型输入形状对应调整即可,网络结构本身不需要改动。

3.3 为什么 TCN 能捕获长程依赖而不梯度消失

这个项目选择 TCN 并不是偶然。RNN 系模型(包括 LSTM、GRU)在处理长输入序列时存在两个痛点:一是按时间步展开后网络层数极深,反向传播路径过长,容易梯度消失;二是 LSTM 的遗忘门和记忆单元引入了大量参数,训练数据不够时很容易过拟合。TCN 的思路完全不同——它本质上是一个前馈网络,卷积核在时间轴上滑动,每一层的梯度传播路径是确定的,不会随时间步拉长而恶化。配合因果卷积对时序方向的约束,TCN 可以在保持并行计算优势的同时,获得比 LSTM 更稳定的长期依赖建模能力。

4. RNN 对照实验:公平条件下比较模型精度

4.1 用 LSTM 搭建传统递归基线

只展示 TCN 的效果而没有对照,说服力是不够的。项目里用 LSTM 作为 RNN 系的代表,搭建了一个结构对称的基线模型。这里有一个细节值得留意——LSTM 的input_shape和 TCN 是同一个三维张量,这保证了两者在输入数据侧完全公平。

from tensorflow.keras.layers import LSTM def build_rnn_model(input_shape): model = tf.keras.Sequential() 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()

LSTM(64)表示隐藏状态维度为 64,与 TCN 中Conv1D(filters=64)的输出通道数保持一致,参数规模对等。Dropout(0.2)同样放在 LSTM 层之后,比率与 TCN 完全一致。这样做的用意是让对照实验尽量只差在“模型结构”这一个变量上,而不是因为参数规模不同导致结果没有可比性。

训练配置保持完全一致:epochs=50, batch_size=32, validation_split=0.1。时序预测的对照实验里这是血泪经验——如果两边用不同的 epoch 或 batch size,最终的精度差异很难说清是模型本身的能力差异还是训练配置导致的。

4.2 预测与 RMSE 评估

模型训练完成后,预测和评估的逻辑要格外小心数据形状的变换。预测出的结果是一维数组,但scaler.inverse_transform需要二维输入,因此必须先 reshape 成[-1, 1]的形状,才能正确还原到原始价格尺度。

from sklearn.metrics import mean_squared_error # TCN 预测 tcn_predictions = tcn_model.predict(X_test) tcn_predictions = scaler.inverse_transform(tcn_predictions) # RNN 预测 rnn_predictions = rnn_model.predict(X_test) rnn_predictions = scaler.inverse_transform(rnn_predictions) # 将 y_test 还原到原始尺度 y_test_original = scaler.inverse_transform(y_test.reshape(-1, 1)) # 分别计算 RMSE tcn_rmse = np.sqrt(mean_squared_error(y_test_original, tcn_predictions)) rnn_rmse = np.sqrt(mean_squared_error(y_test_original, rnn_predictions)) print(f'TCN RMSE: {tcn_rmse}') print(f'RNN RMSE: {rnn_rmse}')

mean_squared_error计算的是预测值与真实值之间平方差的平均值,取平方根后变成 RMSE,单位与原始价格一致,便于直观判断模型的平均预测偏差。数值越小说明模型预测越准。

在实际运行结果中,TCN 的 RMSE 会低于 LSTM 模型,特别是在测试集后期走势波动较大的时间段,TCN 对趋势拐点的跟随能力更强。这与 TCN 的感受野设计有关——多层因果卷积堆叠后,高层卷积能看到更长时间范围内的模式,而 LSTM 虽然理论上有记忆能力,但在 50 个 epoch 的有限训练下,长距离依赖的学习并不充分。

4.3 可视化对比

import matplotlib.pyplot as plt plt.figure(figsize=(14, 7)) plt.plot(y_test_original, 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()

可视化代码有一个注意点:x轴是测试集的样本序号,不是实际日期。如果想让图上横轴显示日期,需要把data.index[train_size + time_step + 1:]传入plt.xticks,否则图形看起来会少一段可解释的时间信息。

运行这份代码后,通常能观察到 TCN 的曲线更紧密地贴合真实价格线,在趋势变化时延迟更短。这正是“TCN 适合处理长输入序列”这一结论的最直观证据。

5. 常见错误梳理:数据形状、归一化与过拟合排查

5.1 三维数组形状不匹配,训练报错

现象:运行tcn_model.fit(X_train, y_train)时抛出类似Input 0 of layer conv1d is incompatible with the layer: expected ndim=3, found ndim=2的异常。

原因:create_dataset返回的 X 初始形状是[样本数, 时间步]的二维数组,没有 reshape 成[样本数, 时间步, 特征数]的三维结构。Conv1D 和 LSTM 都要求输入带特征维度。

解决:在训练前执行X_train = X_train.reshape(X_train.shape[0], X_train.shape[1], 1),最后一个维度代表特征数,单变量预测时为 1。如果要在项目里引入更多特征,把这个数字改成特征总量即可。

5.2 inverse_transform 出现维度错误

现象:预测完成后执行scaler.inverse_transform(tcn_predictions)报错,提示Expected 2D array, got 1D array instead。

原因:MinMaxScaler 的inverse_transform要求输入的 shape 必须与fit_transform时一致。缩放器记录的是每个特征列的最小值和最大值,一维数组不包含列信息,无法进行逆变换。

解决:先 reshape 再逆变换,tcn_predictions = scaler.inverse_transform(tcn_predictions.reshape(-1, 1))。同样的处理也适用于y_test。

5.3 LSTM 与 TCN 输入形状要求不一致

现象:LSTM 模型训练成功,但同一个数据切给 TCN 模型报错。

原因:LSTM 的input_shape=(time_step, features)只接受三维数据,而 Conv1D 的input_shape同样需要三维。两份代码的 reshape 逻辑其实是一样的,区别在于 LSTM 层的return_sequences默认是 False,如果是多层 LSTM 堆叠,中间层必须设成 True,否则下一层收不到序列输出。项目里只有一个 LSTM 层,所以看不出这个差异。

解决:如果要在 RNN 模型里堆叠两层 LSTM,写成LSTM(64, return_sequences=True)(inputs),最后一层 LSTM 接Dense前再设return_sequences=False。

5.4 训练集上损失很低,测试集上一塌糊涂

现象:epochs=50训练结束后,训练集 MSE 降到 0.001 以下,但测试集 RMSE 远高于预期,曲线图上看预测值几乎是一条直线。

原因:时序数据本身存在自相关性,如果用真实价格数据(而非随机模拟数据),训练集和测试集分布会有明显差异——训练集的均值、方差、趋势斜率与测试集不一样。MinMaxScaler 是全量数据拟合的,如果测试集的价格范围超出了训练集的范围,预测值会被压缩到见过的区间里。

解决:第一,改用训练集单独拟合 scaler,scaler.fit(X_train)而不是scaler.fit_transform(data),能更真实地模拟在线预测场景;第二,增加time_step的长度,让模型见过更多历史模式;第三,在 TCN 的卷积层前加BatchNormalization,缓解分布漂移带来的影响。

5.5 训练时间过长,CPU 环境无法忍受

现象:50 个 epoch 跑完需要数小时,而模型结构其实很小。

原因:默认配置下 TensorFlow 不强制使用 GPU,且validation_split=0.1让每个 epoch 多了一次验证集的前向计算。反复建模调参时,这种开销可能造成不必要的等待。

解决:将epochs降到 20,用model.save('tcn_model.h5')保存第一次训练结果。后续需要微调时,用model.load_weights加载权重并设置initial_epoch继续训练。

6. 二次开发:从单步预测到多步预测与真实数据接入

项目跑通之后,最值得做的改造方向有三个:接入真实外汇数据、实现多步预测、给 TCN 增加膨胀卷积和残差连接以提升长程建模能力。

6.1 接入真实数据源

把模拟数据替换成真实数据非常简单。Yahoo Finance 导出的 CSV 通常包含Date, Open, High, Low, Close, Volume列,预测目标一般选择Close列。改造后的数据加载代码长这样:

data = pd.read_csv('forex_historical.csv') data['Date'] = pd.to_datetime(data['Date']) data.set_index('Date', inplace=True) # 只保留收盘价列,并填充缺失值 data = data[['Close']].ffill()

注意真实数据可能存在假期跳空的缺失值,ffill()会用上一交易日价格填充。如果想知道模型对波动高低的敏感度,可以考虑把目标列从Close换成log(Close / Close.shift(1)),即对数收益率序列,这样对非平稳序列的预测更合理。

6.2 加入膨胀卷积与残差连接

当前实现没有显式的dilation_rate参数,这是 TCN 感受野扩展的关键一步。改造后的 TCN 模块保留因果卷积,并增加指数级膨胀系数:

from tensorflow.keras.layers import Conv1D, Add, Activation, Input def build_tcn_residual_block(inputs, filters=64, kernel_size=3, dilation_rate=1): x = Conv1D(filters=filters, kernel_size=kernel_size, padding='causal', dilation_rate=dilation_rate)(inputs) x = Activation('relu')(x) x = Dropout(0.2)(x) x = Conv1D(filters=filters, kernel_size=kernel_size, padding='causal', dilation_rate=dilation_rate)(x) x = Activation('relu')(x) x = Dropout(0.2)(x) # 残差连接:卷积后的输出与原始输入相加 res_x = Conv1D(filters=filters, kernel_size=1)(inputs) x = Add()([x, res_x]) return x

dilation_rate=1时就是普通因果卷积,dilation_rate=2时卷积核每两个时间步采样一次,感受野呈指数级扩大——三个残差块分别设置dilation_rate=1, 2, 4,总感受野能覆盖约 32 个时间步,比当前 7 步的感受野大得多。残差连接的意义在于缓解深层网络训练退化,让梯度能直接回传到浅层。

6.3 输出层改造为多步预测

当前模型每次只预测未来 1 个时间步,这在方向性预测的场合是够用的。但如果是做交易策略回测,通常需要未来 3 到 7 天的预估,这时候最简单的改造方式不是改变网络结构,而是改变标签。把create_dataset中的y.append(data[i + time_step, 0])改成:

def create_multistep_dataset(data, time_step=10, pred_step=5): X, y = [], [] for i in range(len(data) - time_step - pred_step): X.append(data[i:(i + time_step), 0]) # 取未来 pred_step 个点的均值作为预测目标 y.append(data[i + time_step : i + time_step + pred_step, 0].mean()) return np.array(X), np.array(y)

这里取均值是实务中最常见的稳健做法,比预测单个点更平滑。如果想输出未来 5 天的逐日预测,则把输出层换成Dense(pred_step),训练标签改成data[i+time_step : i+time_step+pred_step, 0],并配合return_sequences=False的卷积输出。

6.4 超参数调试的一点经验

TCN 模型的超参数调整有几个优先级:首选dilation_rate,它直接决定模型能看到多远的历史;其次调kernel_size,加大核宽比盲目堆层数更有效;最后才是filters。这三个参数改完再观察训练集和验证集之间的差距,如果验证集 RMSE 不降反升,需要优先加Dropout或降低epochs。

验证方式建议固定随机种子np.random.seed(42)后重新跑一次完整流程,确保改动前和改动后是在同一分布的数据上对比。另外每次跑完用plt.savefig('forecast_compare.png')存一份预测曲线图,能直观判断模型是否有滞后问题。从那以后我再做时序预测实验,都强制要求自己至少准备一条 RNN 基线和一个膨胀卷积系数可调的 TCN,避免踩进“模型结构复杂但效果说不清”的坑里。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进