简介:面向初级及以上数据科学家、机器学习爱好者与时间序列分析从业者,这份项目实例围绕 Python 构建时间卷积神经网络(TCN)完成数值预测展开,重点解决长序列建模中 RNN 易出现的梯度消失与性能下降问题,并以金融市场趋势分析为应用背景。压缩包仅含 1 个 docx 文档,大小约 31KB,内容以图文教程和可复用代码片段为主,便于集中阅读与按步骤实践;目前已有 173 人学习或浏览。文档从环境准备、模拟外汇数据生成、归一化与时间步切分讲起,逐步搭建由因果卷积和膨胀卷积组成的 TCN,并给出 LSTM/RNN 对照模型的训练与预测流程,附执行结果截图及两类网络预测效果对比图。读者可据此掌握数据预处理、模型构建、训练调参和预测可视化的完整链路,也能借鉴参数设置与排错思路,迁移到其他单变量或多变量序列预测任务中。
1. 某开发者丢过来一份 LSTM 训练日志,验证集 loss 从第 30 个 epoch 开始上下波动
同样的数据、同样的滑窗长度,换成 TCN 之后曲线明显平稳了许多。这不是玄学——TCN 把「要记住多久之前的信息」从 RNN 的隐状态里挪到了膨胀卷积的 dilation 参数上,感受野是算得出来的,不是训出来的。这份项目实例给的是一套 Python + TensorFlow 下能直接跑通的完整链路:造模拟行情数据、切滑窗样本、堆因果卷积、和 LSTM 对照、算 RMSE、画预测曲线,程序和数据集都在包里。适合已经用 Keras 搭过简单网络、想在时间序列上换一套骨架的人,也适合被 RNN 调参折腾过、想搞清楚「长依赖到底怎么建模」的从业者。下面按我实际复现的顺序拆:先把数据和形状弄对,再谈模型结构,最后说清哪些参数动了会翻车。
2. 环境与数据管线:从一份价格序列切出模型能吃的三维张量
时间序列项目里,八成以上的「模型不收敛」最后都追到数据管线上:归一化的位置错了、滑窗的边界算错了、训练集和验证集之间漏了未来信息。模型结构反而是最容易改的那部分。这一章把依赖、造数、归一化、切窗四件事按顺序做一遍,代码都能直接跑。
2.1 依赖清单与版本边界
资源里的pip install一行带过,实操时建议拆开装,出问题好定位。
# 建议用独立虚拟环境,避免和系统里已有的 tf 打架 python -m venv tcn-env source tcn-env/bin/activate # Windows 下用 tcn-env\Scripts\activate pip install "numpy>=1.23,<2.0" \ "pandas>=2.0" \ "scikit-learn>=1.3" \ "matplotlib>=3.7" pip install "tensorflow>=2.12,<2.17"版本上我一般守住两条线:一是numpy不要升到 2.x,部分 Keras 版本对 NumPy 2 的 ABI 还没完全对齐,容易在 import 阶段就报_ARRAY_API not found;二是 TensorFlow 的原生 GPU 轮子在 2.10 之后不再随包发布,手上是较新的显卡又特别想用 GPU,就老老实实走 WSL2 或者容器,别在 Windows 原生环境里折腾半天。只用 CPU 跑这个项目完全够——2000 个样本、4 个残差块、几十个 epoch,普通笔记本十几分钟就出结果。
scikit-learn在这里只用来做MinMaxScaler和几个误差指标,不参与建模;matplotlib负责最后的对照图。这几个包版本冲突的概率很低,真正容易出事的是 TensorFlow 和显卡驱动,所以装完先跑一句python -c "import tensorflow as tf; print(tf.__version__, tf.config.list_physical_devices('GPU'))",确认版本和设备都符合预期再往下走。
2.2 造一份可复现的模拟行情数据
资源里用np.random.normal(loc=1.0, scale=0.01).cumsum() + 100造价格,能跑,但量纲不太像真实报价——日波动固定在 1 分钱,且直接对价格做正态累加,理论上存在走到负数的可能。我习惯改到对数域:
import numpy as np import pandas as pd rng = np.random.default_rng(42) # 固定种子,保证每次复现完全一致 n = 2000 dates = pd.date_range(start='2020-01-01', periods=n, freq='B') # B = 工作日 # 1) 对数收益率主体:日波动约 18 个基点 shock = rng.normal(loc=0.0, scale=0.0018, size=n) # 2) 轻微的均值回复:上期涨得多,这期往回拉一点 drift = -0.02 * np.concatenate([[0.0], shock[:-1]]) # 3) 叠一个 21 个工作日的弱周期,模拟月末资金流 cycle = 0.0006 * np.sin(2 * np.pi * np.arange(n) / 21) log_price = np.log(100.0) + np.cumsum(shock + drift + cycle) price = np.exp(log_price) # 天然恒正 df = pd.DataFrame({'date': dates, 'close': price.round(5)}) df.to_csv('fx_sim.csv', index=False) print(df['close'].describe())这段的关键在对数域累加:价格的相对变化是可加的,而对数变换天然把「涨 1%」变成常量,模型学起来更稳,也不会出现负价。scale=0.0018这个数决定了信噪比——调大到 0.01,序列基本变成白噪声,任何模型都学不出东西,RMSE 会逼近标准差,你会误以为是模型不行;调小到 0.0001,序列又太平滑,模型只要输出「上一期」就能拿到极低的误差,看起来效果好得离谱。做对比实验时这两个极端都要试一次,心里才有基准。
均值回复项-0.02是我加的:真实行情里纯粹的随机游走很少,连续单边行情之后往往有回吐。这一项也让「预测下一期」变得不那么平凡——如果序列是纯随机游走,最优预测就是上一期观测值,模型的 RMSE 永远打不过 persistence 基线,这是后面第 4 章要重点讲的事。
2.3 归一化的位置:先切分,再 fit
这是翻车率最高的一步。资源原文是先对整列数据fit_transform,再切训练测试——MinMaxScaler在 fit 的时候会用到全局最大最小值,测试集的信息就这样漏进了训练集,指标会虚高几个点。正确顺序是:先在训练段拟合,再 transform 全量。
from sklearn.preprocessing import MinMaxScaler raw = df['close'].to_numpy(dtype='float64') n = len(raw) # 按时间下标切分,不要按样本数比例硬切 i_train = int(n * 0.70) i_val = int(n * 0.85) scaler = MinMaxScaler(feature_range=(0, 1)) scaler.fit(raw[:i_train].reshape(-1, 1)) # 只看训练段 scaled = scaler.transform(raw.reshape(-1, 1)).ravel() print(f'训练段区间: {raw[:i_train].min():.4f} ~ {raw[:i_train].max():.4f}') print(f'全量区间: {raw.min():.4f} ~ {raw.max():.4f}')如果两个区间差得多,说明后面那 30% 走出了训练段的分布范围,transform之后会出现大于 1 或小于 0 的值——这不是 bug,是模型在推理时必然要面对的外推问题。别为了「让值落在 0~1」去重 fit 全量,那等于提前告诉模型未来会涨到哪。我一般会额外打一行日志记录这个越界比例,超过 20% 就要警惕:要么缩短验证窗口,要么干脆改用StandardScaler并对收益率建模。
feature_range保持默认的(0, 1)就好。用 TCN 这类带激活的卷积网络,输入落在 0~1 比落在 -1~1 收敛略快,差别不大;但如果你后面换成带 BatchNorm 的结构,就建议改成(-1, 1),让均值靠近 0。
2.4 滑窗函数与三维形状
TCN 在 Keras 里的输入形状是(batch, timesteps, channels)。单变量价格序列的channels=1,所以最后要[..., None]补一维。手写循环用list.append在样本量上万时会明显拖慢,我改成一次np.stack:
def make_windows(series, lookback=64, horizon=1): """series: 1D ndarray(已归一化) 返回 X: (N, lookback, 1) y: (N, horizon) """ N = len(series) - lookback - horizon + 1 if N <= 0: raise ValueError('序列长度不足,检查 lookback / horizon') X = np.stack([series[i:i + lookback] for i in range(N)]) y = np.stack([series[i + lookback:i + lookback + horizon] for i in range(N)]) return X[..., None].astype('float32'), y.astype('float32') LOOKBACK, HORIZON = 64, 1 X, y = make_windows(scaled, LOOKBACK, HORIZON) print(X.shape, y.shape) # (1936, 64, 1) (1936, 1)lookback=64不是随手选的:后面 TCN 用 4 个残差块、kernel_size=3、dilation 取[1,2,4,8],感受野算下来是 61 个时间步。窗口长度要略大于感受野,多出来的几个点当作冗余边界,不然最后一个残差块的输出有一部分落在「看不见真实输入」的 padding 上。horizon=1是单步预测;改成 5 就是一次输出未来 5 天,但注意此时的y不是标量,损失和反归一化的写法都要跟着变,第 6 章会讲。
切分必须用时间下标对齐,不能用样本数比例:
def split_by_time(X, y, lookback, horizon, i_train, i_val): end_train = i_train - lookback - horizon + 1 end_val = i_val - lookback - horizon + 1 return (X[:end_train], y[:end_train], X[end_train:end_val], y[end_train:end_val], X[end_val:], y[end_val:]) X_tr, y_tr, X_va, y_va, X_te, y_te = split_by_time( X, y, LOOKBACK, HORIZON, i_train, i_val) print(X_tr.shape, X_va.shape, X_te.shape)这里的偏移量- lookback - horizon + 1很容易漏。样本i的输入覆盖原始下标[i, i+lookback),标签落在i+lookback。想让训练集最后一个标签刚好落在i_train - 1,样本下标就得截到i_train - lookback - horizon + 1。漏掉这个偏移,验证集里就会混进几十个来自训练段的样本,指标会莫名其妙地好——这种「好得不真实」的结果,我踩过不止一次。
3. 把手写 Conv1D 堆成真 TCN:因果卷积、膨胀率与残差块
资源里的模型是三层Conv1D(padding='causal')加一个Dense(1)。它抓住了 TCN 的一半——因果性,但缺了膨胀卷积和残差连接,本质上还是个普通的一维 CNN。这一章把缺的部分补齐,顺带把形状对不上的问题解决掉。
3.1 原料版模型的三个隐患
先看原写法里容易被忽略的地方:
# 资源原文的结构(复现时建议先跑一遍,看输出形状) x = Conv1D(64, 3, padding='causal', activation='relu')(inputs) # (B, T, 64) x = Dropout(0.2)(x) x = Conv1D(64, 3, padding='causal', activation='relu')(x) # (B, T, 64) x = Conv1D(64, 3, padding='causal', activation='relu')(x) # (B, T, 64) outputs = Dense(1)(x) # (B, T, 1)第一个隐患就在最后一行:Dense(1)作用在最后一维上,输出是(B, T, 1)而不是(B, 1)。Keras 在编译时不一定报错(取决于版本,有的会用广播比较,有的直接抛 shape 不匹配),但即便跑起来,模型是在对每个时间步都做一次回归,最后一个时间步的预测只是其中之一,等于浪费了 63 个位置的输出。修法很简单,砍掉时间维再进 Dense:
from tensorflow.keras import layers # 方案 A:只取最后一个时间步,最贴合「预测下一个点」的语义 last = layers.Lambda(lambda t: t[:, -1, :])(x) outputs = layers.Dense(1)(last) # (B, 1) # 方案 B:对整段做平均池化,抗噪更好但会稀释末端信息 # outputs = layers.Dense(1)(layers.GlobalAveragePooling1D()(x))方案 A 是我默认选的。因果卷积保证最后一个时间步的输出只依赖输入窗口内的历史,语义干净。
第二个隐患是没有膨胀卷积,三层kernel_size=3叠下来感受野只有1 + 3×(3-1) = 7个时间步,lookback=64里有 57 个点压根没进梯度。想让模型看远一点,堆层数只会让参数膨胀、训练变慢,正确做法是让 dilation 指数增长。
第三个隐患是缺残差连接。三层以上带激活的卷积堆叠,梯度回传时衰减明显,训练后期 loss 会卡在某个值上下小幅震荡——就是开头说的那个「上下波动」的现象。加一条x + identity的旁路,情况会好很多,后面TCNBlock里会实现。
3.2 因果卷积与膨胀率的取法
padding='causal'的作用是在序列左侧补零,保证t时刻的输出只看到t及之前。这和默认的padding='same'有本质区别——后者会在两边都补零,等于让模型偷看了未来半步。做时序预测,这个参数写错的话离线指标会非常漂亮,上线就立刻崩,属于最贵的一类错误。
膨胀卷积的公式不复杂:第l层的 dilation 取d_l,卷积核大小k,单层额外覆盖(k-1) × d_l个历史步。总感受野:
RF = 1 + Σ (k - 1) × d_l取k=3、d = [1, 2, 4, 8]:
| 层 | dilation | 单层新增覆盖 | 累计感受野 |
|---|---|---|---|
| 输入 | - | - | 1 |
| Block 1 | 1 | 4 | 5 |
| Block 2 | 2 | 8 | 13 |
| Block 3 | 4 | 16 | 29 |
| Block 4 | 8 | 32 | 61 |
61 步的感受野,配lookback=64刚好覆盖满,还留 3 步余量。想覆盖更长的周期,不用改kernel_size,直接往后加d=16的一层就能到 125 步,参数量只涨一层卷积。这就是膨胀卷积相对 RNN 的实打实优势:感受野可精确设计,不靠隐状态去「学」。
我一般按lookback反推层数:while RF < lookback: dilations.append(2**i),保证感受野略大于窗口长度即可。盲目堆层数会让模型的参数远超数据量级,2000 个样本撑不起上百万参数的网络。
3.3 残差块的完整实现
把卷积、归一化、激活、Dropout、旁路投影打包成一个Layer,代码复用度最高:
import tensorflow as tf from tensorflow.keras import layers @tf.keras.utils.register_keras_serializable(package='tcn') class TCNBlock(layers.Layer): def __init__(self, filters, kernel_size=3, dilation_rate=1, dropout=0.2, **kw): super().__init__(**kw) self.conv1 = layers.Conv1D(filters, kernel_size, padding='causal', dilation_rate=dilation_rate) self.conv2 = layers.Conv1D(filters, kernel_size, padding='causal', dilation_rate=dilation_rate) self.norm1 = layers.LayerNormalization() self.norm2 = layers.LayerNormalization() self.drop1 = layers.Dropout(dropout) self.drop2 = layers.Dropout(dropout) self.act = layers.Activation('relu') self.proj = None def build(self, input_shape): # 通道数不一致时,用 1x1 卷积把旁路对齐,否则没法相加 if input_shape[-1] != self.conv1.filters: self.proj = layers.Conv1D(self.conv1.filters, 1) super().build(input_shape) def call(self, inputs, training=False): x = self.conv1(inputs) x = self.norm1(x) x = self.act(x) x = self.drop1(x, training=training) x = self.conv2(x) x = self.norm2(x) x = self.act(x) x = self.drop2(x, training=training) res = self.proj(inputs) if self.proj is not None else inputs return self.act(x + res) # 相加后再过激活,是 TCN 原论文的写法几个参数的选择理由:
filters=64是这套数据量下的稳妥值。样本不到 2000,通道开到 128 以上基本就是过拟合预定了,验证 loss 会在第 10 个 epoch 之后掉头向上。
dropout=0.2放在卷积之后、残差相加之前。注意training参数要显式透传——写成self.drop1(x)在函数式 API 里也工作,但在tf.function编译或model.fit的自定义循环里,Dropout 的行为可能和预期不一致,显式传参是最保险的。
归一化选LayerNormalization而不是BatchNorm1D:时间序列的 batch 内样本来自不同时间段,分布差异天然存在,BatchNorm 用跨样本统计量会把不同时段的特征混在一起;LayerNorm 按单样本的通道维归一化,对时序更友好。代价是它对输入尺度敏感,所以前面归一化那步不能省。
register_keras_serializable别漏。自定义 Layer 不加这个装饰器,model.save('x.keras')之后再load_model会直接报Unknown layer。这个坑我在第 5 章单独说。
组装成完整模型:
def build_tcn(input_shape, filters=64, kernel_size=3, dilations=(1, 2, 4, 8), dropout=0.2): inputs = layers.Input(shape=input_shape) # (T, 1) x = inputs for d in dilations: x = TCNBlock(filters, kernel_size, d, dropout)(x) x = layers.Lambda(lambda t: t[:, -1, :])(x) # 砍时间维 x = layers.Dense(32, activation='relu')(x) outputs = layers.Dense(1)(x) model = tf.keras.Model(inputs, outputs) model.compile(optimizer=tf.keras.optimizers.Adam(1e-3), loss='mse', metrics=['mae']) return model tcn = build_tcn((X_tr.shape[1], 1)) tcn.summary()Dense(32) → Dense(1)这个头是我加的,直接从 64 维接到 1 维也能跑,但加一层过渡在小样本上收敛更平缓。最后的Dense(1)不要激活函数——回归任务输出原始值,加sigmoid会把预测压进 0~1,反归一化之后整体偏低。
3.4 对照组的 LSTM 要怎么配才公平
资源里的 LSTM 是单层 64 单元加 Dropout。做对比时有个容易忽略的点:两个模型的参数量要大致可比,否则你比较的是「大模型 vs 小模型」而不是「两种结构」。上面这个 TCN 大概 5 万参数,单层 LSTM(64) 约 1.7 万,差了三倍。
def build_lstm(input_shape, units=64, dropout=0.2): m = tf.keras.Sequential([ layers.Input(shape=input_shape), layers.LSTM(units), # 默认只返回最后一步,形状 (B, units) layers.Dropout(dropout), layers.Dense(32, activation='relu'), layers.Dense(1) ]) m.compile(optimizer=tf.keras.optimizers.Adam(1e-3), loss='mse', metrics=['mae']) return mLSTM(units)默认return_sequences=False,输出就是二维的,不需要再砍时间维——这一点和 TCN 不同,也是很多人从 CNN 切到 RNN 时形状对不上的原因。想让参数量靠拢,把units提到 160 左右,或者叠两层 96 单元。我在对比实验里通常跑三组:TCN、LSTM(参数量对齐)、persistence 基线,三个数摆在一起才有说服力。
训练配置两边保持一致:同样的 batch size、同样的优化器、同样的早停策略。任何一边单独调过学习率,比较结果就不作数了。训练代码:
cbs = [ tf.keras.callbacks.EarlyStopping(monitor='val_loss', patience=8, restore_best_weights=True), tf.keras.callbacks.ReduceLROnPlateau(monitor='val_loss', factor=0.5, patience=4, min_lr=1e-5) ] hist_tcn = tcn.fit(X_tr, y_tr, validation_data=(X_va, y_va), epochs=80, batch_size=32, callbacks=cbs, verbose=2) hist_lstm = build_lstm((X_tr.shape[1], 1)).fit( X_tr, y_tr, validation_data=(X_va, y_va), epochs=80, batch_size=32, callbacks=cbs, verbose=2)restore_best_weights=True是必须的,否则早停之后拿到的是最后一轮的权重,而不是验证 loss 最低那一轮,指标会莫名差一截。patience=8配ReduceLROnPlateau的patience=4,是个比较通用的组合:先降学习率试探,再决定要不要停。
4. 训练与评估:RMSE 之外还得盯住的三条线
跑出两个 RMSE 数字就下结论,是时间序列项目里最常见的草率。价格序列有很强的自相关性,一个「永远输出上一期观测值」的模型就能拿到看起来不错的 RMSE。这一章把评估补齐,让对比结论站得住。
4.1 反归一化的顺序:先 reshape 还是先 inverse
MinMaxScaler.inverse_transform只接受二维数组(n_samples, n_features)。单步预测时model.predict返回(N, 1),正好能直接喂;多步预测返回(N, horizon),就得先摊平再折回来:
import numpy as np def inverse(scaler, arr): """arr: (N,) 或 (N, h),返回同形状的原始量纲""" a = np.asarray(arr) shape = a.shape flat = a.reshape(-1, 1) # 摊成 (N*h, 1) return scaler.inverse_transform(flat).reshape(shape) pred_tcn = inverse(scaler, tcn.predict(X_te, verbose=0)) pred_lstm = inverse(scaler, build_lstm((X_te.shape[1], 1)).predict(X_te, verbose=0)) true = inverse(scaler, y_te)顺序反了(先 inverse 再 reshape)在多特征场景下会静默出错——inverse_transform按列套用各自的 min/max,摊平的时候列顺序乱了,结果不会报错,只是数字全错。单特征时侥幸没事,所以我建议从一开始就固定写成「先 reshape(-1,1) 再 inverse 再 reshape 回来」这个模板,换数据集不用改。
顺带说,y_te也走同一个inverse函数,别图省事手写scaler.inverse_transform(y_te)——y_te是(N, 1)能跑通,但一旦horizon变了就会炸。
4.2 加一条 persistence 基线,RMSE 才有意义
def rmse(a, b): return float(np.sqrt(np.mean((np.asarray(a) - np.asarray(b)) ** 2))) def mae(a, b): return float(np.mean(np.abs(np.asarray(a) - np.asarray(b)))) def direction_acc(pred, true): p = np.sign(np.diff(np.asarray(pred).ravel())) t = np.sign(np.diff(np.asarray(true).ravel())) return float((p == t).mean()) # 基线:直接把窗口最后一个观测值当成下一期预测 naive = inverse(scaler, X_te[:, -1, 0].reshape(-1, 1)) print(f'persistence RMSE={rmse(naive, true):.5f} MAE={mae(naive, true):.5f}') print(f'TCN RMSE={rmse(pred_tcn, true):.5f} MAE={mae(pred_tcn, true):.5f} ' f'DirAcc={direction_acc(pred_tcn, true):.3f}') print(f'LSTM RMSE={rmse(pred_lstm, true):.5f} MAE={mae(pred_lstm, true):.5f} ' f'DirAcc={direction_acc(pred_lstm, true):.3f}')判断标准很直接:TCN 的 RMSE 必须明显低于 persistence,否则模型没学到任何超出「上一期」的信息。我自己的经验阈值是低 15% 以上才算有信号,低 5% 以内基本可以认为是在拟合噪声。这个基线只要三行代码,但它是唯一能防止你自欺欺人的东西。
direction_acc(方向准确率)是第二个必看的指标。RMSE 低但方向准确率只有 0.5,说明模型学到的是「幅度」而不是「方向」,对交易类场景毫无价值。计算时用np.diff对比相邻两期的符号,np.sign会把 0 也归进去,样本量大时影响可以忽略。
如果数据集里价格的量级差异很大(比如从 100 涨到 150),MAPE 会比 RMSE 更直观,但价格接近 0 时 MAPE 会炸,用之前先确认分母。我一般三个指标一起打,不单独依赖任何一个。
4.3 预测曲线怎么画才看得出问题
一张只有两条曲线的图,很难看出模型到底哪一段错了。我习惯画成上下两栏:上面叠三条线(真实、TCN、persistence),下面单独画残差。
import matplotlib.pyplot as plt fig, axes = plt.subplots(2, 1, figsize=(13, 8), sharex=True, gridspec_kw={'height_ratios': [2, 1]}) axes[0].plot(true, label='actual', lw=1.3, color='#1f4e79') axes[0].plot(pred_tcn, label='TCN', lw=1.0, color='#d97706', alpha=0.9) axes[0].plot(pred_lstm, label='LSTM', lw=0.9, color='#2e7d32', alpha=0.7) axes[0].plot(naive, label='persistence', lw=0.8, color='gray', ls='--', alpha=0.6) axes[0].set_ylabel('price') axes[0].legend(loc='upper left', fontsize=9) resid = pred_tcn.ravel() - true.ravel() axes[1].plot(resid, lw=0.7, color='crimson') axes[1].axhline(0, color='k', lw=0.6) axes[1].fill_between(range(len(resid)), resid, 0, alpha=0.15, color='crimson') axes[1].set_ylabel('residual') axes[1].set_xlabel('test step') plt.tight_layout() plt.savefig('pred_vs_true.png', dpi=140) plt.show()看图的顺序:先看上面三条线是不是整体贴在一起——如果 TCN 和 persistence 几乎重合,模型就是没学到东西;再看下面残差有没有明显的系统性偏移,残差均值显著不为 0,说明反归一化或者 scaler 用错了;最后看残差有没有随时间放大的趋势(异方差),有的话说明模型对波动率变化不敏感,可以考虑对收益率建模而不是对价格建模。
还有一种常见形态:TCN 的曲线整体比真实值「晚半步」,形状对但相位滞后。这不是 bug,是单步预测的固有特性——在强自相关序列上,模型最省力的解就是输出一个平滑过的上一期。要缓解,一是把horizon拉长让它必须给出多步,二是把输入从「价格」换成「收益率 + 价格」双通道,让它有别的信息可用。
5. 避坑与排查:五条让我返工过的记录
下面这五条都是我在复现这类项目时真实撞过的,按「现象 → 原因 → 解决」写,遇到对应症状可以直接对号入座。
第一条:训练到第 2 个 epoch 就出现loss: nan。
现象很直接,fit的日志里 loss 从 0.03 跳到 nan 再也没回来。原因通常有两个:一是忘了归一化,原始价格在 100 附近,MSE 的梯度量级过大,Adam 一步就把权重推到发散;二是数据里混进了 NaN 或者 inf,MinMaxScaler遇到 NaN 会算出一个 NaN 的 min/max,之后全军覆没。解决顺序是先df.isna().sum()查缺失,再确认scaled的np.isfinite(scaled).all()为 True,最后把学习率从1e-3降到1e-4试一轮。加一句tf.keras.optimizers.Adam(1e-3, clipnorm=1.0)做梯度裁剪,能挡住大部分偶发发散。
第二条:验证集 loss 比训练集还低,而且低一大截。
第一反应是「模型没学」,实际多半是数据泄漏。检查点有三个:归一化是不是在全量上 fit 的;滑窗切分有没有用时间下标对齐(第 2.4 节那个偏移量);validation_split和手动切分的验证集是不是重复了——fit里的validation_split=0.1是从传入数据末尾切,如果你已经手动切过验证集,等于验证了两次且两次的范围不同。我的做法是统一用手动切分的validation_data=(X_va, y_va),把validation_split全部去掉,只保留一个来源。
还有一个小概率原因:LayerNormalization在training=False下的行为如果和你想的不一样(它没有 running stats,两种模式一致,但如果你换成了BatchNorm1D就会差很多)。换成 BatchNorm 之后验证集指标反超训练集,那是正常的(用 batch 统计量 vs 滑动平均),别误判成泄漏。
第三条:load_model报Unknown layer: TCNBlock。
现象是训练完保存、换个脚本加载就挂。原因是自定义 Layer 没有注册序列化标识,Keras 存.keras或.h5时只记了类名,加载时找不到对应实现。解决就是第 3.3 节那个装饰器:
@tf.keras.utils.register_keras_serializable(package='tcn') class TCNBlock(layers.Layer): ...如果模型已经存了又不想重训,加载时手动传:
from tensorflow.keras.models import load_model m = load_model('tcn.keras', custom_objects={'TCNBlock': TCNBlock})注意custom_objects的键名要和保存时的类名完全一致,改过类名的情况对不上。这也是我现在坚持在每个自定义 Layer 上加装饰器的原因——省下的那行代码,后面要用两次调试来还。
第四条:预测值全都挤在同一条水平线上。
模型输出几乎是常数,波动幅度远小于真实值。原因一般是Dropout开得太大(0.5以上)加上样本量小,网络学到的就是一个「平均值解」;或者Dense输出层误加了sigmoid,把值压进 0~1 之后反归一化,动态范围被压缩。排查方法:先打印tcn.predict(X_te[:10])的原始输出,如果方差接近 0,把 dropout 降到 0.1~0.2 重训一轮;再看最后一层有没有激活函数,回归任务必须是线性的。还有一种情况是学习率太低、训练轮数不够,模型还停在初始化的平坦区域,这种情况 loss 曲线会是一条几乎水平的线,很好识别。
第五条:同一份代码,两次运行结果差很多。
固定了np.random.seed(42)还是有波动,因为 TensorFlow 自己的随机源没固定。完整做法:
import os, random os.environ['PYTHONHASHSEED'] = '42' os.environ['TF_DETERMINISTIC_OPS'] = '1' # 强制确定性算子 random.seed(42) np.random.seed(42) tf.random.set_seed(42)即便如此,GPU 上的某些卷积实现仍有非确定性,TF_DETERMINISTIC_OPS=1会让它回退到确定性版本,代价是速度下降。做对比实验时我干脆强制 CPU:os.environ['CUDA_VISIBLE_DEVICES'] = '-1',这点数据量 CPU 跑得动,结果可复现比快几十秒重要得多。另外model.fit的shuffle默认是 True,时序数据上保持默认即可(打乱的是样本顺序,不是样本内部的时间顺序),但如果你的滑窗有重叠,打乱会让相邻样本分到不同 batch,影响不大,不必特意关掉。
6. 进阶:从单步推到多步滚动,以及我现在的验证习惯
单步预测的实用价值有限,真正要用的时候基本都得往多步走。最省事的做法是滚动递归:训好的单步模型反复调用,把上一次的输出塞回窗口。
def rollout(model, seed_window, steps, scaler): """seed_window: (lookback, 1) 已归一化;steps: 往前推多少步""" win = np.asarray(seed_window, dtype='float32').copy() outs = [] for _ in range(steps): nxt = model.predict(win[None, :, :], verbose=0)[0, 0] outs.append(nxt) win = np.vstack([win[1:], [[nxt]]]) # 滑窗前进一格 return scaler.inverse_transform(np.array(outs).reshape(-1, 1)).ravel() seed = X_te[0] # 测试集第一个窗口 path = rollout(tcn, seed, steps=30, scaler=scaler) print(np.round(path[:5], 4))这段代码有三个必须知道的边界。第一,误差会累积——每推一步,输入里就多一个模型自己编的值,推到 30 步之后基本已经偏离真实轨迹,所以滚动预测的可信区间通常只到 5~10 步;第二,predict在循环里调用会有明显的 Python 开销,步数多的话把model.predict换成model(win[None], training=False).numpy()快不少;第三,win[1:]是视图切片,np.vstack会复制,内存上没问题,但别在里面用np.append,那是 O(n) 拷贝,循环几百次就明显卡。
更稳的路子是直接把horizon设成想要的步数,一次输出多步,损失用 MSE 对每个位置加权:
HORIZON = 5 Xm, ym = make_windows(scaled, LOOKBACK, HORIZON) # ym: (N, 5) # 模型最后一层改成 Dense(HORIZON),其余不变多步直接输出避免了误差累积,但会稀释每个位置的监督信号,通常需要更多数据才能训稳。我的经验是样本少于 5000 时用滚动递归,多于 5000 时用直接多步,两者也可以混着来——先直接出 3 步,再对这 3 步做一次滚动修正。
验证习惯这块,我现在固定走三步:先跑 persistence 基线并把数字写进实验记录的第一行,再跑一个「打乱标签」的对照实验(把y_tr随机重排后训练,如果 RMSE 和正常训练差不多,说明模型压根没学到时序结构),最后才看 TCN 和 LSTM 的对比。第二步特别管用,它能在十分钟内告诉你「这份数据里到底有没有可学的信号」,省掉后面几小时的调参。
从那以后我每接一个时间序列的活,都先把 persistence 基线和打乱标签对照跑出来贴在实验记录最上面,再谈模型选型。希望帮到你。
本文还有配套的精品资源,点击获取