简介:「Pytorch复现STGCN:基于图卷积时空神经网络在交通速度中的预测」是一份面向交通预测与深度学习方向的算法学习者、科研入门者的复现笔记,围绕 IJCAI 2018 论文《Spatio-Temporal Graph Convolutional Networks》展开。内容从论文思路、摘要与引言切入,梳理图卷积 GCN、时空卷积块 ST-Conv Block、门控一维卷积 GLU 实现的时间卷积、切比雪夫近似的空间图卷积以及 PeMSD7(M) 数据集上的数据处理,并给出网络架构与 L2 损失下的模型输出说明,适合对照源码理解 STGCN 在交通速度预测中的建模逻辑。资源为单个 pdf 文档,压缩包约 408KB,已有 4486 人学习下载。读者可借此理清图结构时空依赖建模的关键环节,为交通流预测的复现与调参提供一条清晰的参考路径。
1. 交通速度预测为什么绕不开 STGCN:从时序模型到图卷积的转折
在 PeMS 这类交通速度数据上,很多人第一反应是上 LSTM 或者 Transformer,单点预测的 MAE 看起来还能接受,但把结果铺回整张路网就会发现明显掉链子。原因不复杂:这些模型把每个传感器当成独立序列来处理,而现实中上游路段一旦拥堵,下游在几分钟内必然被拖慢,这种依赖关系是写在路网拓扑里的。STGCN(Spatio-Temporal Graph Convolutional Network,时空图卷积网络)做的事情就是把这个拓扑显式地编码进模型:用图卷积分支沿着邻接矩阵聚合相邻节点的信息,用门控一维卷积分支沿着时间轴提取趋势,两者堆叠成时空卷积块。因为图卷积用切比雪夫多项式做谱近似、时间卷积只有一个很小的卷积核,整个网络的参数量比循环结构少一个数量级,训练也更快。接下来要落地的具体问题是:手里有一份「时间 × 节点」的速度矩阵和一份邻接矩阵,怎么在 PyTorch 里从零把这个结构复现出来,邻接矩阵怎么归一化、切比雪夫阶数怎么选、训练怎么不发散、多步预测怎么组织输出。
2. STGCN 的时空图卷积原理与 PyTorch 环境落地
2.1 交通路网怎么变成图:邻接矩阵与节点特征
STGCN 的第一个前置条件是把路网抽象成一张带权无向图 G = (V, E, A)。节点 V 就是检测器或传感器断面,边权 A[i][j] 表示两个断面之间的空间关联强度。PeMSD7 系列数据集的常见做法是基于真实路网距离构造高斯核邻接矩阵:先算出任意两个节点的路网距离 d(i, j),再按阈值 κ 截断,距离超过 κ 的直接置 0,避免远距离节点互相干扰;阈值以内的按 exp(-d²/σ²) 赋权。这样构造出来的 A 是稀疏且对称的,对角线上通常补 1,表示节点自身的信息也要保留。
节点特征本身很简单,就是每个时间片的速度值。但组织成张量之后维度顺序很容易搞错,建议一开始就把形状约定写死:
| 符号 | 形状 | 含义 |
|---|---|---|
| X | (S, C, T_in, N) | 输入张量,S 是样本数,C 是通道数(速度单变量取 1),T_in 是历史步数,N 是节点数 |
| Y | (S, N, T_out) | 预测标签,多步预测直接给出 T_out 个未来时间片 |
| A | (N, N) | 原始邻接矩阵 |
| L_tilde | (N, N) | 缩放后的拉普拉斯矩阵,喂给图卷积层 |
把形状定死之后,后面所有报错基本都能通过打印.shape定位。5 分钟采样间隔下 T_in = 12 对应过去 1 小时,T_out = 12 对应未来 1 小时,这是最常被引用的配置。
提示:邻接矩阵一定要跟速度矩阵的节点顺序严格对齐。如果速度和距离来自两份不同来源的文件,先按节点 ID 排序再构造,否则模型学到的空间关系完全是错的,而且指标还看不出异常。
2.2 切比雪夫图卷积与门控时间卷积的原理对比
STGCN 的核心是把「空间卷积」和「时间卷积」拆开交替做,而不是像循环网络那样一次性建模。空间侧用的是切比雪夫多项式近似谱图卷积:对归一化拉普拉斯 L 做特征值缩放,把谱半径压到 [-1, 1],然后用递推式 T_k(x) = 2x·T_{k-1}(x) - T_{k-2}(x) 计算 K 阶项,最终输出是各阶项加权求和。K 取 3 是常见选择,对应每个节点聚合三跳以内的邻居,再大收益很小但计算量线性增长。
时间侧用的是门控线性单元加一维卷积,而不是 LSTM。一维卷积沿时间轴滑动,卷积核共享到所有节点,感受野由 kernel_size 决定;门控部分把卷积输出按通道一分为二,一半过 sigmoid 当门,另一半与之逐元素相乘,这样模型能自主决定哪些时间片的信息该保留。两者对比可以列成一张表:
| 建模方式 | 空间依赖 | 时间依赖 | 参数量级 | 主要问题 |
|---|---|---|---|---|
| LSTM | 无,各节点独立 | 循环结构 | 大 | 忽略路网拓扑,长序列训练慢 |
| CNN + 全连接 | 隐式,靠全连接学 | 一维卷积 | 中大 | 参数量随节点数平方增长 |
| 图卷积 + 门控时间卷积 | 显式,由 A 决定 | 一维卷积 | 小 | 依赖 A 的质量,A 错了效果直接崩 |
一个完整的时空卷积块按「时间卷积 → 图卷积 → 时间卷积」的顺序排列,中间的图卷积把时间步并入 batch 维单独处理节点维度,两侧的时间卷积负责压缩和恢复时间维度,块外用层归一化和残差连接兜住梯度。
2.3 conda 安装 PyTorch 与环境自检
环境这一步踩坑的人最多,尤其是 GPU 版本对不上。先在命令行确认驱动和 CUDA 运行时版本,再决定装哪个 wheel:
# 1. 查看驱动支持的最高 CUDA 版本,右上角 CUDA Version 是上限 nvidia-smi # 2. 建一个独立环境,避免和已有的 tensorflow / comfyui 环境互相污染 conda create -n stgcn python=3.10 -y conda activate stgcn # 3. 按需选择:GPU 版走官方源,CPU 版直接装默认 wheel pip install torch --index-url https://download.pytorch.org/whl/cu121 pip install numpy pandas scipy scikit-learn matplotlib tqdm # 4. 自检:三项都要打印 True / 版本号,缺一项说明装错了 python -c "import torch; print(torch.__version__, torch.cuda.is_available(), torch.cuda.device_count())"参数说明:python=3.10是为了跟主流 wheel 对齐,太新的解释器有时没有预编译包;cu121这类后缀必须小于等于nvidia-smi显示的 CUDA Version,装高了会在第一次.to('cuda')时报错。Windows 上如果遇到OSError: [WinError 1114] 动态链接库(DLL)初始化例程失败 ... c10.dll,绝大多数情况是缺 Visual C++ 运行库或者 CPU 版和 GPU 版混装过,处理方式是卸干净重装并补上 VC++ Redistributable,而不是去改 PATH。
2.4 数据集切分、滑动窗口与 z-score 归一化
原始速度矩阵是 (T_total, N),需要切成监督学习样本。滑动窗口写法固定,注意归一化统计量只能用训练集算:
import numpy as np def zscore_fit(data): """只用训练段拟合统计量,避免未来信息泄漏""" mu = data.mean(axis=0, keepdims=True) sigma = data.std(axis=0, keepdims=True) + 1e-6 return mu, sigma def make_windows(data, T_in=12, T_out=12): """data: (T_total, N) -> X:(S,1,T_in,N) Y:(S,N,T_out)""" X, Y = [], [] for t in range(len(data) - T_in - T_out + 1): X.append(data[t:t + T_in]) # (T_in, N) Y.append(data[t + T_in:t + T_in + T_out].T) # (N, T_out) X = np.asarray(X)[:, None, :, :] # 增加通道维 return X.astype(np.float32), np.asarray(Y).astype(np.float32) # 按时间顺序 6:2:2 切分,绝对不能 shuffle 后再切 n = len(speed) train_raw = speed[:int(n * 0.6)] mu, sigma = zscore_fit(train_raw) speed_norm = (speed - mu) / sigma X, Y = make_windows(speed_norm) Xtr, Ytr = X[:int(len(X) * 0.6)], Y[:int(len(Y) * 0.6)]逻辑说明:zscore_fit在每个节点维度上独立求均值和标准差,因为不同断面的速度量纲和分布差异很大,全局归一化会让慢速路段被淹没。切分必须按时间顺序,任何随机打乱都会让相邻时间片跨集合,验证指标虚高。反归一化时对预测结果执行pred * sigma + mu,注意 sigma 的形状是 (1, N),要对齐到标签的 (S, N, T_out) 上。
3. 用 PyTorch 手写 STGCN 核心模块:图卷积块与时空块
3.1 邻接矩阵的对称归一化与拉普拉斯构造
图卷积能不能训起来,八成取决于拉普拉斯构造得对不对。标准流程是先对称归一化 A,再算 L = I - D^(-1/2) A D^(-1/2),最后把特征值缩放到 [-1, 1] 供切比雪夫递推使用。如果不做缩放,高阶项会随着阶数指数放大,训练几个 batch 就出现 NaN。
import numpy as np def scaled_laplacian(A): """对称归一化 + 特征值缩放,返回 L_tilde 供切比雪夫递推""" N = A.shape[0] d = A.sum(axis=1) d_inv_sqrt = np.zeros_like(d, dtype=np.float64) nz = d > 0 d_inv_sqrt[nz] = np.power(d[nz], -0.5) # 孤立节点度数为 0,单独置 0 L = np.eye(N) - d_inv_sqrt[:, None] * A * d_inv_sqrt[None, :] lambda_max = np.linalg.eigvalsh(L).max() # 对称矩阵用 eigvalsh,比 eig 快且无虚部 return (2.0 / lambda_max) * L - np.eye(N)参数说明:d_inv_sqrt里必须做d > 0判断,PeMS 里偶尔有孤立节点,np.power(0, -0.5)会直接产出 inf,然后整个矩阵被污染。lambda_max用eigvalsh求,因为 L 是对称实矩阵,虚部恒为 0,用eig反而引入浮点误差。N = 228 时求一次特征值在秒级,N = 1026 时建议缓存成.npy文件复用,别每次启动都重算。
3.2 切比雪夫图卷积层 ChebConv 的 PyTorch 实现
图卷积层只需要保存 K 组权重,前向时先递推生成各阶项,再逐阶加权求和。写成 einsum 比循环 matmul 更清晰:
import torch import torch.nn as nn class ChebConv(nn.Module): """K 阶切比雪夫图卷积:out = sum_k T_k(L_tilde) @ X @ W_k""" def __init__(self, in_channels, out_channels, K): super().__init__() self.K = K self.weight = nn.Parameter(torch.empty(K, in_channels, out_channels)) nn.init.xavier_uniform_(self.weight) # 初始量级过大会让 LayerNorm 前激活爆炸 self.bias = nn.Parameter(torch.zeros(out_channels)) def forward(self, x, L_tilde): # x: (B, N, C_in) L_tilde: (N, N) T = [x, torch.einsum('nm,bmc->bnc', L_tilde, x)] for k in range(2, self.K): T.append(2 * torch.einsum('nm,bmc->bnc', L_tilde, T[-1]) - T[-2]) out = sum(torch.einsum('bnc,kco->bno', T[k], self.weight[k]) for k in range(self.K)) return out + self.bias逻辑说明:T列表按递推式逐阶生成,第 0 阶是自身特征,第 1 阶是邻居聚合,第 k 阶表示 k 跳邻域。einsum('nm,bmc->bnc')把 (N, N) 的传播矩阵作用在 batch 里每个样本的节点维度上,这一步就是图卷积区别于普通全连接的实质。K 一般取 3,调到 4 以上时参数量线性增长而指标改善往往在 1% 以内,性价比很低。
3.3 门控时间卷积 GLU 与时空卷积块 STConvBlock
时间卷积沿 T 轴滑动,节点维当作共享权重的空间维,这样参数量与节点数无关。门控部分用 chunk 把输出切成两半:
class TemporalConv(nn.Module): def __init__(self, in_channels, out_channels, kernel_size=3): super().__init__() self.kernel_size = kernel_size self.conv = nn.Conv2d(in_channels, 2 * out_channels, (kernel_size, 1)) def forward(self, x): # x: (B, C_in, T, N) x = nn.functional.pad(x, (0, 0, self.kernel_size - 1, 0)) # 只在时间轴左侧补零 x = self.conv(x) p, q = torch.chunk(x, 2, dim=1) # 一半当信息,一半当门 return p * torch.sigmoid(q)参数说明:padding的顺序是 (左, 右, 上, 下),对应最后两个维度 (N, T),所以(0, 0, K-1, 0)表示只沿时间轴左侧补 K-1 个零,保证当前时刻看不到未来,这一点在预测任务里是硬要求。2 * out_channels的通道翻倍是为了给门控留位置,实际输出通道数仍是out_channels。
把三种层拼起来就是时空卷积块,注意图卷积前要把时间步并入 batch 维:
class STConvBlock(nn.Module): def __init__(self, in_channels, hidden, out_channels, K, kernel_size=3, dropout=0.5): super().__init__() self.temporal1 = TemporalConv(in_channels, hidden, kernel_size) self.graph = ChebConv(hidden, hidden, K) self.temporal2 = TemporalConv(hidden, out_channels, kernel_size) self.norm = nn.LayerNorm(out_channels) self.dropout = nn.Dropout(dropout) self.residual = (nn.Conv2d(in_channels, out_channels, 1) if in_channels != out_channels else nn.Identity()) def forward(self, x, L_tilde): h = self.temporal1(x) # (B, hidden, T, N) B, C, T, N = h.shape h = h.permute(0, 2, 3, 1).reshape(B * T, N, C) # 时间步并入 batch h = self.graph(h, L_tilde) # (B*T, N, hidden) h = h.reshape(B, T, N, -1).permute(0, 3, 1, 2) # 还原成 (B, hidden, T, N) h = self.temporal2(h) h = h.permute(0, 2, 3, 1) res = self.residual(x).permute(0, 2, 3, 1) return self.dropout(self.norm(h + res)).permute(0, 3, 1, 2)permute + reshape的两步必须成对出现,前面的 permute 把通道挪到最后,后面的 permute 负责还原,中间如果少了.contiguous(),某些 PyTorch 版本上 reshape 会直接抛错。
| 层级 | 输入形状 | 输出形状 | 作用 |
|---|---|---|---|
| TemporalConv 1 | (B, C_in, T, N) | (B, hidden, T, N) | 时间维升维、压缩感受野 |
| ChebConv | (B*T, N, hidden) | (B*T, N, hidden) | 按 A 聚合空间邻居 |
| TemporalConv 2 | (B, hidden, T, N) | (B, C_out, T, N) | 时间维降回目标通道 |
| LayerNorm + 残差 | (B, T, N, C_out) | 同上 | 稳住深层的激活量级 |
3.4 堆叠输出层与整体模型组装
输出层做两件事:先用时间卷积把通道降下来,再用一个线性层沿时间轴把 T_in 压成 T_out。这样多步预测变成一次前向,不需要自回归迭代。
class OutputBlock(nn.Module): def __init__(self, in_channels, T_in, T_out): super().__init__() self.temporal = TemporalConv(in_channels, 1, kernel_size=3) self.fc = nn.Linear(T_in, T_out) def forward(self, x): # x: (B, C_in, T_in, N) -> (B, N, T_out) h = self.temporal(x).permute(0, 3, 1, 2) # (B, N, 1, T_in) return self.fc(h).squeeze(2) # (B, N, T_out) class STGCN(nn.Module): def __init__(self, num_nodes, T_in=12, T_out=12, K=3, channels=(64, 64, 64)): super().__init__() c1, c2, c3 = channels self.block1 = STConvBlock(1, c1, c1, K) self.block2 = STConvBlock(c1, c2, c2, K) self.block3 = STConvBlock(c2, c3, c3, K) self.out = OutputBlock(c3, T_in, T_out) def forward(self, x, L_tilde): for blk in (self.block1, self.block2, self.block3): x = blk(x, L_tilde) return self.out(x)参数说明:channels=(64, 64, 64)是 STGCN 里最常被沿用的三层配置,N = 228 时显存占用大约在 2~3 GB;节点数涨到 1026 时第一层通道建议降到 32,否则中间张量 (B, 64, 12, 1026) 会把显存吃满。K=3对应三跳邻域,改动前先确认邻接矩阵的稀疏度,如果图本身很稠密,减小 K 比调通道数更有效。
4. PeMS 数据上的训练循环、关键参数与排错
4.1 损失函数、Adam 优化器与学习率衰减
回归任务直接用均方误差,配 Adam 加阶梯衰减就够了,不需要复杂技巧。关键是梯度裁剪要有,因为切比雪夫高阶项在训练初期偶尔会给出比较大的梯度。
import torch import torch.nn as nn device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = STGCN(num_nodes=A.shape[0]).to(device) L_tilde_t = torch.tensor(scaled_laplacian(A), dtype=torch.float32, device=device) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.7) criterion = nn.MSELoss() for epoch in range(50): model.train() total = 0.0 for xb, yb in train_loader: xb, yb = xb.to(device), yb.to(device) optimizer.zero_grad() loss = criterion(model(xb, L_tilde_t), yb) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) # 防高阶项梯度尖峰 optimizer.step() total += loss.item() * xb.size(0) scheduler.step() print(f'epoch {epoch:02d} lr={optimizer.param_groups[0]["lr"]:.2e} loss={total/len(train_set):.5f}')参数说明:lr=1e-3配合step_size=5, gamma=0.7是常见配置,50 轮内学习率衰减到 1e-3 × 0.7^10 ≈ 2.8e-5。如果验证损失在第 10 轮就开始上升,先减小初始学习率到 5e-4,而不是急着加正则。clip_grad_norm_的阈值 5.0 对这批数据够用,设成 1.0 会明显拖慢收敛。
| 超参数 | 建议值 | 调整方向 | 影响 |
|---|---|---|---|
| T_in | 12 | 6 / 24 | 历史窗口太短抓不到周期,太长涨显存 |
| T_out | 12 | 与评估粒度对齐 | 越长误差累积越明显 |
| batch_size | 32~64 | 显存允许就加大 | 过小会让 LayerNorm 统计量抖动 |
| K | 3 | 2~4 | 阶数过高收益递减 |
| dropout | 0.5 | 0.3~0.5 | 过拟合明显时上调 |
| lr | 1e-3 | 5e-4~1e-3 | 出现 NaN 优先下调 |
4.2 输入张量形状与 batch 组织方式
DataLoader 出来的张量形状必须和模型约定一致,形状错位是新手最常见的卡点。把 numpy 数组转成 TensorDataset 时顺手确认一次:
from torch.utils.data import TensorDataset, DataLoader train_set = TensorDataset(torch.from_numpy(Xtr), torch.from_numpy(Ytr)) train_loader = DataLoader(train_set, batch_size=64, shuffle=True, num_workers=2, pin_memory=True) xb, yb = next(iter(train_loader)) print(xb.shape, yb.shape) # 期望 torch.Size([64, 1, 12, N]) torch.Size([64, N, 12]) pred = model(xb.to(device), L_tilde_t) print(pred.shape) # 期望 torch.Size([64, N, 12])逻辑说明:训练集可以 shuffle,但时间上相邻的样本被分到不同 batch 是允许的,因为每个样本自身已经包含了完整的历史窗口,不存在标签泄漏。pin_memory=True只在 GPU 训练时开,CPU 训练反而拖慢。如果打印出来的 xb 形状是[64, 12, N],说明make_windows里漏了增加通道维的那一步,直接补[:, None, :, :]。
4.3 CUDA、显存与 DLL 初始化失败的排查
报错按类型分成三类处理最省时间。第一类是环境类,torch.cuda.is_available()返回 False,或者加载c10.dll失败,属于安装问题,回退到 2.3 节重装并确认 VC++ 运行库齐全,不要试图用降级 numpy 之类的偏方绕过。第二类是显存类,典型信息是CUDA out of memory,处理顺序是先降 batch_size,再降第一层通道数,最后考虑把 L_tilde 从 float32 转成稀疏张量。第三类是数值类,loss 在几个 batch 内变成 NaN,检查三处:拉普拉斯有没有 inf、学习率是否过大、输入有没有未处理的缺失值。
缺失值这块值得单独说。原始 PeMS 数据里存在速度为 0 或空白的时间片,如果不处理,归一化后会出现极端负值,几个样本就能把梯度带偏。常见做法是用前后时间片的线性插值补齐,再统一做 z-score,并在训练时对原始值为 0 的位置计算掩码损失,避免模型去拟合无效点。
注意:
clip_grad_norm_只能缓解梯度尖峰,治不了根本的数值问题。如果加了裁剪还是 NaN,一定是某个张量里存在 inf 或 nan,用torch.isnan(x).any()逐层打点定位,比盲调学习率快得多。
4.4 MAE/RMSE/MAPE 指标与预测曲线怎么读
三个指标各有盲区,必须一起看。MAE 反映平均绝对偏差,单位就是速度单位;RMSE 对大误差更敏感,通常比 MAE 明显大,两者差距过大说明存在个别离谱的预测点;MAPE 是百分比,但速度接近 0 时会爆炸,必须加掩码。
def masked_mape(pred, true, eps=1.0): """eps 单位是 km/h,低于该阈值的真实值不参与 MAPE 计算""" mask = true > eps if mask.sum() == 0: return torch.tensor(0.0) return (torch.abs((pred[mask] - true[mask]) / true[mask])).mean() * 100 def evaluate(model, loader, mu, sigma): model.eval() preds, trues = [], [] with torch.no_grad(): for xb, yb in loader: out = model(xb.to(device), L_tilde_t).cpu() # 反归一化后再算指标,否则数值没有物理意义 preds.append(out * torch.tensor(sigma) + torch.tensor(mu)) trues.append(yb * torch.tensor(sigma) + torch.tensor(mu)) p, t = torch.cat(preds), torch.cat(trues) mae = torch.abs(p - t).mean() rmse = torch.sqrt(((p - t) ** 2).mean()) print(f'MAE={mae:.2f} RMSE={rmse:.2f} MAPE={masked_mape(p, t):.2f}%')参数说明:eps=1.0是为了过滤掉接近静止的路段,不设阈值时 MAPE 动辄上百。反归一化必须用测试集之外拟合的 mu / sigma,但评估时的反归一化用的还是训练集统计量,这一点容易写错。除了看数字,建议按预测步长分别画 15 / 30 / 45 / 60 分钟的误差曲线,通常会看到前 30 分钟误差缓慢上升,超过 45 分钟后陡增,这个拐点决定了 T_out 该设多大。
5. 自适应邻接矩阵与多步预测:把 STGCN 调到能用的技巧
5.1 让图结构自己学:自适应邻接矩阵
基于距离的邻接矩阵有个天然缺陷:它只表示地理邻近,而两个相距很远的路段完全可能因为通勤流向而强相关。自适应图卷积的思路是给每个节点学一个低维嵌入,用嵌入内积生成关联强度,让 A 跟着数据一起更新。
class AdaptiveAdj(nn.Module): def __init__(self, num_nodes, emb_dim=10): super().__init__() self.E1 = nn.Parameter(torch.randn(num_nodes, emb_dim) * 0.1) self.E2 = nn.Parameter(torch.randn(num_nodes, emb_dim) * 0.1) def forward(self): # ReLU 截掉负关联,softmax 逐行归一化后可直接当传播矩阵用 return torch.softmax(torch.relu(self.E1 @ self.E2.T), dim=1)参数说明:emb_dim=10是常用取值,节点数越大可以适当上调到 16,但超过 32 后容易过拟合。得到的矩阵已经是行随机矩阵,可以直接和物理邻接矩阵做加权融合:A_final = alpha * A_phys + (1 - alpha) * A_adp,alpha 取 0.5 是个不错的起点。
这里有个性能坑必须提醒:如果每次前向都对A_final做特征分解算lambda_max,训练会慢到无法接受。工程上的做法是放弃严格的特征值缩放,直接对A_final + I做对称归一化得到传播矩阵,用它替代L_tilde参与前向计算,精度损失通常在可接受范围内,但训练速度能提升一个数量级。融合权重 alpha 和嵌入维度都建议单独做一轮网格,这两个参数对结果的影响比 K 的取值大得多。
5.2 多步预测的输出组织与误差累积对照
多步预测有两种实现路线:一种是像 3.4 节那样用线性层一次性输出 T_out 步,另一种是自回归地每次预测一步再喂回去。前者训练时误差不会累积,但长程依赖靠的是最后一层线性映射;后者理论上能建模更长的依赖,实际却会因为单步误差被反复放大。
| 输出方式 | 训练方式 | 60 分钟误差 | 显存 | 适用场景 |
|---|---|---|---|---|
| 单次线性输出 | 端到端 | 较低 | 低 | 固定预测步长,T_out ≤ 12 |
| 自回归迭代 | 逐步或端到端 | 明显偏高 | 高 | 需要任意长度预测 |
| 多任务输出头 | 端到端 | 最低 | 中 | 同时要 15/30/45/60 分钟结果 |
第三种做法值得优先尝试:在输出层挂 T_out 个独立的线性头,每个头负责一个预测步长,损失函数里给远期步长更高的权重,比如 15 分钟权重 1.0、60 分钟权重 1.5。这样模型不会为了照顾近期精度而牺牲远期表现,训练时只在最后几层做加法,代价很小。
5.3 消融对照与冻结微调
评估一个改动是否真的有效,必须做消融对照,否则很容易把随机波动当成改进。固定随机种子,跑三组配置:只有物理邻接矩阵、只有自适应矩阵、两者融合,其他超参数完全不动,对比验证集 MAE。常见结果是融合版本比单用物理邻接矩阵低 3%~8%,而单用自适应矩阵在训练集上表现最好,验证集上却不如物理版本,这就是过拟合的典型信号,此时应该提高 alpha 的取值。
另一个能省大量时间的技巧是冻结微调。当节点数从 228 扩到 1026,重新训练太贵,可以复用已训练模型的时间卷积分支和输出层,只重新初始化图卷积层和自适应嵌入,用较小的学习率 3e-4 训练 10 轮左右,让空间部分适应新图的规模。冻结哪一层用param.requires_grad = False控制,但一定要把ChebConv里的 weight 和AdaptiveAdj的 E1、E2 保持可训练,否则整个模型等于只学了个时间卷积,空间信息全丢。判断微调是否起效的方法很简单:对比微调前后的验证集 MAE,如果 10 轮内没有下降 5% 以上,直接换回全量训练,别在冻结策略上继续耗。
本文还有配套的精品资源,点击获取