简介:这份压缩包面向初级及以上数据科学家、机器学习爱好者及时间序列分析从业者,提供用 Python 与 TensorFlow 构建时间卷积神经网络(TCN)完成序列预测的完整项目实例,重点解决长序列依赖建模与趋势预测问题,并以汇率/外汇行情数据为示范场景。包内仅含 1 个 docx 文档,压缩包约 31KB,正文把理论说明、环境准备、数据生成与预处理、TCN 因果卷积和膨胀卷积结构、模型训练、预测可视化等环节串成一条可复现路径,同时给出 LSTM/RNN 的对照实现与效果比较图,便于理解 TCN 相对循环网络在精度和长程依赖上的优势。现有 173 人学习,适合据此调整时间步、卷积核、Dropout 与训练轮次,完成自己的预测实验并沉淀排错与调参思路。
1. 预测曲线越往后越平:这套 TCN 方案要解决什么问题
有人拿 LSTM 做金融价格序列预测,训练 loss 降得很漂亮,可把预测曲线画出来,前五六步还能贴上真实值,再往后就变成一条近乎水平的直线。他换了两份数据、调了三轮学习率,曲线还是平的。问题不在数据,而在模型组织历史信息的方式:循环结构把整段历史压进一个隐藏状态,序列一长,早期信息被反复稀释。这套用 Python 加 TensorFlow/Keras 实现的时间卷积神经网络(TCN)方案,正是冲着这个场景来的——用因果卷积保证不看未来,用膨胀卷积把感受野成倍撑开,再和 RNN 做一组同口径对照实验。它适合要交一份完整时间序列预测毕设或课设的人,也适合手上有一份带时间戳的 CSV、想跑通「数据—建模—对比—评估」全链路的人。
2. 数据与样本构造:把一维价格序列变成模型能吃的 3D 张量
2.1 数据从哪来:真实序列与模拟序列的准备方式
项目实例用随机游走生成模拟数据,好处是可复现、没有版权和数据接口的麻烦。真实行情导出的 CSV 结构基本一致,只要保留「日期 + 价格」两列就能无缝替换。生成模拟序列的代码不长,但每个参数都影响后面的对照实验是否有效:
import numpy as np import pandas as pd # 固定随机种子,保证任何人跑出来的数据完全一致 np.random.seed(42) # 1000 个交易日,日均漂移 1.0,日波动率 0.01 dates = pd.date_range(start='2020-01-01', periods=1000, freq='D') steps = np.random.normal(loc=1.0, scale=0.01, size=len(dates)) # 累加生成随机游走序列,再加一个基准价 100 prices = steps.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()把独立增量累加成一条有趋势、有惯性的路径。这一步别偷懒改成np.random.rand或者直接random.normal不累加——白噪声序列没有记忆结构,任何模型都能拟合得差不多,做 TCN 和 RNN 的对照实验时两条曲线几乎重合,结论站不住脚。我一般会顺手把生成脚本和 CSV 一起放进项目目录,答辩时被问到「数据哪来的」,直接翻脚本比口头解释可信。
2.2 滑窗切样本:time_step 决定模型往前看多远
原始序列是一维的,模型需要的是「一段历史 → 下一个值」这样的监督样本对。切窗函数只有几行,但窗口长度time_step是整个项目最关键的超参数之一:
def create_dataset(series, time_step=10): X, y = [], [] for i in range(len(series) - time_step): # 取连续 time_step 个点作为输入窗口 X.append(series[i:(i + time_step)]) # 窗口之后紧邻的那一个点作为标签 y.append(series[i + time_step]) return np.array(X), np.array(y)标签取的是窗口外紧邻的下一个点,不是窗口内任意一点。这一点经常被写错:写成y.append(series[i])就成了自回归重构任务,模型学的是「还原输入」,评估指标会好看得离谱,但完全没有预测能力。time_step的取值要和模型感受野对齐——选 10 就表示模型每次看最近 10 个交易日,如果网络感受野只有 7 步,多出来的 3 步等于白喂,如果感受野有 29 步,那 19 步的信息其实没被利用上,属于浪费算力。选 10 然后配一个感受野 ≥10 的网络结构,是这套方案里比较稳的搭配。
2.3 归一化的顺序:先切分,再拟合
归一化本身不难,难的是顺序。常见写法是先对整份数据fit_transform,再切训练测试集,这样测试集的均值和方差信息已经泄漏进 scaler 了。正确的顺序是:
from sklearn.preprocessing import MinMaxScaler values = data[['Price']].values # 保持二维,方便 scaler 处理 scaler = MinMaxScaler(feature_range=(0, 1)) # 只在训练段上拟合,测试段只做 transform train_raw = values[:800] test_raw = values[800:] scaler.fit(train_raw) scaled = scaler.transform(values) # 全量转换,但参数只来自训练段MinMaxScaler把价格压到 0 到 1 之间,fit只吃训练段 800 个点。泄漏的后果在单变量、平稳序列上不明显,一旦序列有明显趋势,测试集的极值大概率落在训练集范围之外,transform出来的值会超出 [0,1],模型在训练时从没见过这种输入,误差会被放大,而你从 loss 曲线上完全看不出来。这个坑属于典型的「不出事则已,出事查一天」。
2.4 形状对齐:reshape 到 (样本数, 时间步, 特征数)
Keras 的Conv1D和LSTM都要求三维输入,形状是(样本数, 时间步, 特征数)。切完窗之后是二维的(样本数, 时间步),必须补一维:
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) # 标签保持 (样本数, 1),和模型输出维度对齐 y_train = y_train.reshape(-1, 1) y_test = y_test.reshape(-1, 1)最后一维的1是特征数,单变量预测就是 1。想扩展成多变量(价格 + 成交量 + 波动率),只需要把输入改成(样本数, 时间步, 3),网络结构一行都不用改,这也是卷积类模型比 RNN 省心的地方。标签一定要手动 reshape 成(n, 1),如果留成(n,),后面算 loss 时 Keras 会做隐式广播,训练照常进行、指标照常输出,但数值是错的——这个现象在第 5 章会单独展开。
3. 模型结构设计:因果卷积与膨胀卷积怎么叠出 29 步感受野
3.1 causal padding:让第 t 步只看到 t 及以前
时间序列预测有一条底线:预测第 t+1 个点时,不能用 t+1 及以后的信息。普通Conv1D配padding='same'时,卷积核以当前点为中心,输出第 t 个位置会同时读到 t-1 和 t+1,等于把答案提前告诉了模型。Keras 提供的padding='causal'会在序列左侧补零,让卷积核只覆盖当前点及历史点:
from tensorflow.keras import layers # 因果卷积:输出第 t 步只依赖输入的第 t 步及之前 x = layers.Conv1D(filters=64, kernel_size=3, padding='causal', activation='relu')(inputs)kernel_size=3配causal之后,第 t 个输出由输入的第 t-2、t-1、t 三个位置计算得到,右侧不补零。这个细节决定了模型能不能上线:训练阶段用same也能把 loss 训下去,因为训练时未来信息确实存在,指标会漂亮得反常,可一旦做真正的滚动预测,每一步都得等未来数据到位,模型直接失效。判断方式很土但有效——把输入序列最后 5 个点改成 0,看输出最后 5 个位置有没有变化,有变化就说明卷积看到了右侧。
3.2 感受野手算:7 步为什么不够,29 步从哪来
感受野指输出一个点实际能看到的输入长度,公式是:
感受野 = 1 + Σ (kernel_size - 1) × dilation_rate按项目实例里三层、每层kernel_size=3、无膨胀的写法,感受野是1 + 2×(1+1+1) = 7步。而time_step设的是 10,意味着输入窗口最后 3 个点对输出没有任何贡献,模型在最需要近期信息的部位反而「看不见」。把膨胀率按 1、2、4 阶梯排布,感受野立刻不一样:
| 层序 | kernel_size | dilation_rate | 该层新增感受野 | 累计感受野 |
|---|---|---|---|---|
| 第 1 层 | 3 | 1 | 2 | 3 |
| 第 2 层 | 3 | 1 | 2 | 5 |
| 第 3 层 | 3 | 2 | 4 | 9 |
| 第 4 层 | 3 | 2 | 4 | 13 |
| 第 5 层 | 3 | 4 | 8 | 21 |
| 第 6 层 | 3 | 4 | 8 | 29 |
六层卷积、参数总量并不大,感受野却从 7 步扩到 29 步,覆盖 10 步窗口绰绰有余。这就是 TCN 相对 RNN 的核心优势:想看得更远,加膨胀率就行,不用把隐藏状态越堆越长,也不会有梯度沿时间步反复相乘的问题。写论文或答辩 PPT 时,这张表比任何文字描述都有说服力,评审一眼就能看出你算过。
3.3 从最小版到工程版:残差块与输出头
直接把几层Conv1D串起来能跑,但层数一多就会出现退化和梯度问题。工程上的常规做法是套残差块,每个块里两层因果卷积,Add之前把输入投影到相同通道数:
from tensorflow.keras import layers, Model, Input def residual_block(x, filters, kernel_size, dilation_rate, dropout=0.2): shortcut = x out = layers.Conv1D(filters, kernel_size, padding='causal', dilation_rate=dilation_rate)(x) out = layers.BatchNormalization()(out) out = layers.Activation('relu')(out) out = layers.Dropout(dropout)(out) out = layers.Conv1D(filters, kernel_size, padding='causal', dilation_rate=dilation_rate)(out) out = layers.BatchNormalization()(out) out = layers.Activation('relu')(out) out = layers.Dropout(dropout)(out) # 通道数不一致时用 1x1 卷积对齐,保证能相加 if shortcut.shape[-1] != filters: shortcut = layers.Conv1D(filters, 1, padding='same')(shortcut) return layers.Activation('relu')(layers.Add()([out, shortcut])) def build_tcn_model(input_shape, filters=64, kernel_size=3): inputs = Input(shape=input_shape) x = inputs # 膨胀率按 1,2,4 阶梯上升,每个膨胀率叠两个残差块 for dilation in (1, 2, 4): x = residual_block(x, filters, kernel_size, dilation) x = residual_block(x, filters, kernel_size, dilation) # 只取最后一个时间步的输出,接全连接做单点回归 x = layers.Lambda(lambda t: t[:, -1, :])(x) outputs = layers.Dense(1)(x) model = Model(inputs, outputs) model.compile(optimizer='adam', loss='mean_squared_error') return modelresidual_block里的BatchNormalization放在激活之前,是 TCN 论文里的常规顺序;Dropout放在每个块的末尾,用来压过拟合,0.2 是个安全的起点。Lambda(lambda t: t[:, -1, :])这一行是整个结构里最容易被忽略、又最要命的地方:它把(batch, time, filters)压成(batch, filters),只保留最后一步。如果省掉这一步、直接接Dense(1),输出会变成(batch, time, 1),而标签是(batch, 1),Keras 会静默广播成(batch, time, 1)对(batch, 1)逐个时间步比较,模型很快学会「所有时间步输出同一个值」——loss 照样下降,预测值也像模像样,但它其实没在预测,只是在回归均值。这个坑属于血泪经验级,第 5 章还会再提一次。
3.4 损失函数与训练配置:MSE 之外还要看什么
编译时用mean_squared_error是对齐评估指标的合理选择,因为后面算的是 RMSE,训练目标和评估目标同一量纲,优化方向不会跑偏。如果数据里偶发跳空较多,可以换成huber降低异常点影响,但换完记得在报告里说明,别一边换损失一边还用 RMSE 和基线比。
训练配置上,epochs=50、batch_size=32、validation_split=0.1是项目实例给的起点,实测中更稳的做法是加早停和學習率衰减,避免后 20 轮纯粹在过拟合验证集上抖动:
from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau callbacks = [ # 验证损失连续 8 轮不降就停,并回滚到最优权重 EarlyStopping(monitor='val_loss', patience=8, restore_best_weights=True), # 平台期把学习率减半,帮助跳出局部震荡 ReduceLROnPlateau(monitor='val_loss', factor=0.5, patience=3, min_lr=1e-5) ]patience=8的意思不是「训练 8 轮」,而是「连续 8 轮没有改善才停」。restore_best_weights=True必须开,否则模型停在最后一次更新的权重上,可能恰好是最差的一轮。这两个回调加起来不到十行,但对最终 RMSE 的影响,往往比把网络加深一层更明显。
4. 对照实验与评估:TCN 与 LSTM 的同口径比较方案
4.1 基线对齐:LSTM 必须和 TCN 用同一份数据、同一个切分
对照实验最容易翻车的地方不是模型本身,而是两边吃的东西不一样。TCN 用了time_step=10、80/20 切分、训练段拟合的 scaler,LSTM 也必须完全一致,否则比出来的差值没有意义。基线模型不需要复杂,一层 LSTM 加 Dropout 足够:
import tensorflow as tf from tensorflow.keras import layers, Sequential def build_rnn_model(input_shape, units=64): model = Sequential([ layers.LSTM(units, input_shape=input_shape), layers.Dropout(0.2), layers.Dense(1) ]) model.compile(optimizer='adam', loss='mean_squared_error') return modelunits=64和 TCN 的filters=64保持同量级,Dropout(0.2)、优化器、损失函数全部对齐。唯一不同的是结构本身,这样结论才干净。如果时间允许,再加一组GRU或者单层Conv1D做消融,能回答「提升到底来自卷积还是来自膨胀」这个问题,答辩时是个加分项。
4.2 训练过程:两个模型用同一份回调
两个模型各自训练,但回调列表、batch、epoch 上限全部相同:
history_tcn = tcn_model.fit( X_train, y_train, epochs=60, batch_size=32, validation_split=0.1, callbacks=callbacks, verbose=0 ) history_rnn = rnn_model.fit( X_train, y_train, epochs=60, batch_size=32, validation_split=0.1, callbacks=callbacks, verbose=0 )verbose=0是为了让输出干净,训练曲线后面从history.history里取。两个模型的早停轮数大概率不同,这很正常,只要记录下各自的实际训练轮数写进报告就行。有人担心「TCN 停得早是不是吃亏」,其实早停看的是验证损失,停了就说明继续训只会过拟合,不存在吃亏。
4.3 反归一化与 RMSE:两种算法不能混着用
评估环节有个隐蔽的坑:预测值和真实值必须处在同一个空间里再算误差。常见写法是只反归一化预测值,真实值却用了原始尺度,或者反过来,误差直接被缩放系数放大或缩小几十倍。稳妥的做法是两边都走一遍逆变换:
import numpy as np from sklearn.metrics import mean_squared_error # 两边都还原到原始价格尺度 y_true = scaler.inverse_transform(y_test) tcn_pred = scaler.inverse_transform(tcn_model.predict(X_test)) rnn_pred = scaler.inverse_transform(rnn_model.predict(X_test)) tcn_rmse = np.sqrt(mean_squared_error(y_true, tcn_pred)) rnn_rmse = np.sqrt(mean_squared_error(y_true, rnn_pred)) print(f'TCN RMSE: {tcn_rmse:.4f}') print(f'RNN RMSE: {rnn_rmse:.4f}')inverse_transform要求输入是二维(n, 1),所以y_test必须提前 reshape,否则会报维度错误。另一种写法是在归一化空间里算 MSE,再乘以(max - min)还原量纲,数学上等价,但两种方法不能混用在同一组对比里。RMSE 的单位和原始价格一致,方便直接向非技术背景的人解释「平均误差大概多少」。
4.4 可视化:三条曲线怎么画才看得出差异
单看 RMSE 数字比较干,答辩现场需要一张能一眼看出差别的图:
import matplotlib.pyplot as plt plt.figure(figsize=(14, 6)) plt.plot(y_true, label='Actual', color='#1f4e79', linewidth=1.6) plt.plot(tcn_pred, label='TCN', color='#e07b39', linewidth=1.3) plt.plot(rnn_pred, label='LSTM', color='#4a8f4a', linewidth=1.3, alpha=0.85) plt.title('Time Series Forecast Comparison') plt.xlabel('Sample Index') plt.ylabel('Value') plt.legend() plt.tight_layout() plt.savefig('prediction_compare.png', dpi=150)三条线画在同一张图上,重点看两处:拐点位置谁的滞后更小,平稳段谁的抖动更小。如果两条预测线几乎重叠,说明差异被平均掉了,这时候要补一张「误差绝对值随时间变化」的图,或者按时间分段统计 RMSE,比如前 25%、中间 50%、后 25% 分开算,趋势差异才会暴露出来。这个技巧在写实验分析那一节时非常好用。
5. 避坑与排查:五个让 RMSE 直接失真的常见问题
5.1 归一化用了全量数据
现象:训练集指标正常,测试集 RMSE 明显偏高,而且怎么调网络都压不下去。原因是MinMaxScaler在切分之前就对整份数据fit了,测试段的极值提前影响了缩放参数,模型在训练时相当于偷看过测试集的分布。解决方式固定为「先切分、只在训练段 fit、两边统一 transform」,并且把这段顺序写进代码注释,避免下次重构时又被改回去。
5.2 输出形状与标签形状不匹配
现象:loss 稳定下降,预测曲线却像一条被压平的线,或者所有样本输出同一个值。原因出在Dense(1)直接接在Conv1D后面,输出(batch, time, 1)和标签(batch, 1)发生广播,每个时间步都在和同一个标签算误差。解决方式是在Dense之前用Lambda(lambda t: t[:, -1, :])或GlobalAveragePooling1D()把时间维压掉,并在model.summary()里确认最后一层输出是(None, 1)。
5.3 反归一化时两个数组尺度不一致
现象:RMSE 数值大得离谱,或者小得反常,和肉眼看到的曲线偏差对不上。原因是一边做了逆变换、另一边还是 0 到 1 之间的归一化值。解决办法是把还原动作收敛到一个函数里,真实值和预测值都走同一个函数,并且断言两边形状一致:
def to_price(arr): arr = np.asarray(arr).reshape(-1, 1) return scaler.inverse_transform(arr)5.4 划分数据集时打乱了时间顺序
现象:验证损失比训练损失还低,模型表现「异常好」。原因是用了train_test_split(shuffle=True)之类的操作,把未来样本混进了训练集。时间序列的切分只有一种正确姿势:按时间先后截断,前 80% 训练、后 20% 测试,验证集从训练段尾部再切一刀。任何随机打乱都会让评估结果失去参考价值。
5.5 单次实验就下结论
现象:这次 TCN 比 LSTM 好 3%,换个种子再跑就反过来了。神经网络训练本身有随机性,权重初始化、Dropout、batch 顺序都会影响结果。比较稳的做法是固定三个随机种子各跑一遍,报告均值和标准差,结论写成「在三个种子下 TCN 的 RMSE 均值为 X,低于 LSTM 的 Y」,比单次结果的绝对值可信得多。
6. 验收与答辩:用三张图和一套对照表证明模型真的更强
验收这套方案,不需要把训练日志从头翻到尾,看三个东西就够。
第一张是训练曲线,训练损失和验证损失要同步下降、尾部没有明显张口,张口就是过拟合,说明 Dropout 或早停没配好。第二张是预测叠加图,重点看拐点处的滞后和长序列末端的收敛情况,TCN 的典型优势就在这两处。第三张是误差分布图,把两个模型的绝对误差画成直方图或者按时间分段统计,均值接近时看分布形状,谁的尾部更薄谁就更稳。
对照表建议按下面这几列组织,答辩时一页就能讲完:
| 模型 | 参数量 | 训练轮数 | RMSE(均值±标准差) | 推理耗时(ms/样本) |
|---|---|---|---|---|
| TCN | 约 12 万 | 依早停而定 | 记录三次种子结果 | 记录实测值 |
| LSTM | 约 1.7 万 | 依早停而定 | 记录三次种子结果 | 记录实测值 |
参数量这一列容易被忽略,但它能回答「为什么不用更大的模型」这类提问。TCN 参数量通常比同宽度的 LSTM 多,换来的是可并行计算和更稳定的长程依赖,推理耗时上卷积往往还更快,这组数据摆出来,选型理由就闭环了。
答辩时高频被问的三个问题,提前准备好答案:为什么不用 Transformer——参数量和数据量不匹配,这点样本量下注意力机制很难训稳,而且 TCN 的感受野可以通过膨胀率精确控制,解释成本更低;time_step为什么选 10——和感受野 29 匹配,同时做了 5、10、20 三组对比,10 的验证损失最低;RMSE 差这么小算不算显著——三次种子的标准差已经给出,差值大于标准差才敢说更好。
从那以后我每次带人做对照实验,都强制走一遍「固定种子 + 三次重复 + 分段误差」的流程,宁可多花半天,也不拿一次跑出来的数字去写结论。希望帮到你。
本文还有配套的精品资源,点击获取