简介:这份文档面向已具备PyTorch与深度学习基础的交通预测学习者与研究者,围绕IJCAI 2018论文《Spatio-Temporal Graph Convolutional Networks》展开STGCN的复现与代码解析。内容从论文思路、摘要与引言梳理入手,依次讲解PeMSD7(M)等数据集由网格转图结构的处理方式、以切比雪夫近似谱图卷积提取空间特征的图卷积模块、用GLU实现一维因果卷积的门控时间卷积模块,以及由两个时间卷积与一个空间卷积构成的“三明治”式ST-Conv Block,并说明L2损失、时空块堆叠与输出层如何生成下一时刻速度图,同时整理了GCN、时空卷积块与深度学习在交通研究中的应用等知识点。文中还提到图由距离关系数学构建而非实际路网,纯卷积结构训练更快、参数更少,在中长期交通预测上更具优势。资源包仅1个PDF文件,约408KB,结构紧凑便于通读。目前已有4486人学习下载,适合希望吃透STGCN原理、对照论文与源码理解模型细节的读者。
1. 从 ARIMA 到 STGCN:交通速度预测为什么需要图卷积
很多做交通速度预测的工程师第一反应是上 LSTM,但 IJCAI 2018 的 STGCN 论文给了一个反直觉结论:纯卷积结构训练速度比基于 RNN 的模型快 10 倍以上,参数还更少。它要解决的是短期和中长期交通预测里的两个硬骨头:路网不是规则网格,CNN 的滑动窗口没法直接套;RNN 按时间步迭代,误差会累积,还难并行。论文在 PeMSD7(M) 和 METR-LA 上验证,把交通路网当成图,用切比雪夫一阶近似的图卷积抓空间依赖,用门控一维卷积抓时间依赖。适合正在做时空序列、图卷积神经网络、交通流预测的读者;如果你刚完成 pytorch 安装和 anaconda 配置 pytorch 环境,这个复现比只读 pytorch 教程更能建立 pytorch 张量基础。
2. Chebyshev 一阶近似下的空间图卷积:从路网距离矩阵到 A_hat
2.1 交通路网不是规则网格:为什么 CNN 核在图上失效
交通速度预测里,每个检测器或路段是一个节点,节点之间通过道路连通或空间距离产生关联。规则图像里,卷积核按 3x3 邻域滑动;路网没有上下左右,只有邻接关系。STGCN 采用 Kipf & Welling 2017 的近似谱图卷积,把图信号变换到谱域再滤波,最终化简成一阶形式,只用到邻接矩阵和度矩阵。论文里图的构建不是基于实际路网,而是通过数学方法根据距离关系建图。PeMSD7(M) 有 228 个节点,METR-LA 有 207 个节点,每个节点只取速度一个特征。常见做法是距离阈值高斯核:设节点 i、j 距离 d_ij,若 d_ij <= κ,则权重 exp(-(d_ij/σ)^2),否则 0。这样得到的 A 是稀疏矩阵,既能表达空间邻近,又避免全连接。与自适应图卷积相比,这种静态图构建更轻,但需要道路距离或节点坐标。下面表格列出几种图构建方式的差异。
| 图构建方式 | 输入 | 优点 | 限制 |
|---|---|---|---|
| 距离阈值高斯核 | 节点坐标/路段距离 | 稀疏、物理含义清楚 | 阈值 κ 和 σ 要调 |
| 实际路网邻接 | 拓扑连接 | 真实连通性 | 数据难获取 |
| 自适应图卷积 | 可学习节点嵌入 | 不依赖先验图 | 参数和显存增加 |
| 单位阵+邻接 | 已有 A | 实现简单 | 忽略距离强度 |
2.2 距离图构建与归一化邻接矩阵的代码实现
源码里get_normalized_adj处理邻接矩阵,返回度归一化后的 A_wave。它的作用是把原始邻接矩阵变成图卷积直接可用的形式,同时把自身节点也纳入聚合范围。
import numpy as np def get_normalized_adj(A): """ Returns the degree normalized adjacency matrix. """ A = A + np.diag(np.ones(A.shape[0], dtype=np.float32)) D = np.array(np.sum(A, axis=1)).reshape((-1,)) D[D <= 10e-5] = 10e-5 # Prevent infs diag = np.reciprocal(np.sqrt(D)) A_wave = np.multiply(np.multiply(diag.reshape((-1, 1)), A), diag.reshape((1, -1))) return A_wave这段代码先执行A = A + I,表示卷积时不仅看邻居,也看节点自身状态。D是度矩阵,diag是D^{-1/2},最后用两次np.multiply完成左右归一化,等价于D^{-1/2} (A+I) D^{-1/2}。参数上,A.shape[0]是节点数,METR-LA 里是 207,PeMSD7(M) 里是 228;10e-5用来防止孤立节点的度为零导致inf。返回矩阵是float32,和后面 PyTorch 张量保持一致。
如果手里只有节点坐标或路段距离,常见做法是先构造距离矩阵,再用高斯核转成权重:
def distance_gaussian_kernel(dist, kappa=0.5, sigma=0.5): # dist: (N, N) 距离矩阵,单位与 kappa 一致 W = np.exp(-(dist / sigma) ** 2) W[dist > kappa] = 0.0 np.fill_diagonal(W, 1.0) return W.astype(np.float32)kappa控制邻域半径,超过就断开;sigma控制衰减速度,越小则远处节点权重掉得越快。注意距离矩阵最好先做归一化,否则sigma在不同城市、不同单位下没有可比性。np.fill_diagonal(W, 1.0)是给自身边一个明确权重,后面还会再加单位阵,所以这里也可以置 0,只要保持逻辑一致。
2.3 Chebyshev 多项式近似与一阶化简的工程取舍
原始谱图卷积需要特征分解,复杂度高,图一大就难算。Chebyshev 近似用多项式T_k逼近滤波器,截断到 K 阶。Kipf & Welling 取 K=1 并重归一化,得到gθ * x ≈ θ (I + D^{-1/2} A D^{-1/2}) x。STGCN 直接把A_hat与输入特征相乘,再用Theta矩阵做通道变换。源码里的图卷积核心只有两行:
lfs = torch.einsum("ij,jklm->kilm", [A_hat, t.permute(1, 0, 2, 3)]) t2 = F.relu(torch.matmul(lfs, self.Theta1))A_hat形状是(N, N),t形状是(B, N, T, C),先permute(1, 0, 2, 3)变成(N, B, T, C)。einsum("ij,jklm->kilm")用i, j做节点维聚合,输出(B, N, T, C),也就是每个节点把邻居特征加权求和。接着matmul(lfs, self.Theta1)在最后一维做通道变换,Theta1形状是(C, spatial_channels),把 64 通道压到 16 通道再进入下一个时间卷积。spatial_channels=16是瓶颈策略的一部分,目的是减少参数和计算量,同时让空间与时间模块的通道数对齐。这个一阶近似只保留局部邻域,换更远的依赖要靠堆叠 ST-Conv Block 来扩大感受野。
3. PyTorch 数据管道:Z-score、滑窗与 [B,H,W,C] 的张量契约
3.1 METR-LA 原始张量的读取与 Z-score 归一化
METR-LA 的node_values.npy常见形状是(34272, 207, 2),表示 34272 个时间片、207 个节点、2 个特征。源码用transpose((1, 2, 0))转成(207, 2, 34272),让节点维在前,方便后面按节点采样。归一化用 Z-score,对每个特征维度单独减均值、除标准差。
def load_metr_la_data(): if (not os.path.isfile("data/adj_mat.npy") or not os.path.isfile("data/node_values.npy")): with zipfile.ZipFile("data/METR-LA.zip", 'r') as zip_ref: zip_ref.extractall("data/") A = np.load("data/adj_mat.npy") X = np.load("data/node_values.npy").transpose((1, 2, 0)) X = X.astype(np.float32) means = np.mean(X, axis=(0, 2)) X = X - means.reshape(1, -1, 1) stds = np.std(X, axis=(0, 2)) X = X / stds.reshape(1, -1, 1) return A, X, means, stdsaxis=(0, 2)表示在节点维和时间维上求统计量,保留特征维,所以means和stds形状都是(2,)。means.reshape(1, -1, 1)把(2,)变成(1, 2, 1),才能和(207, 2, 34272)做广播。两个统计量必须保存下来,因为评估时要反变换回速度单位。注意X.astype(np.float32)放在归一化前,避免后续除法产生float64张量。
| 变量 | 形状 | 含义 |
|---|---|---|
node_values.npy原始 | (34272, 207, 2) | 时间, 节点, 特征 |
X转置后 | (207, 2, 34272) | 节点, 特征, 时间 |
means/stds | (2,) | 每个特征的均值和标准差 |
A | (207, 207) | 邻接矩阵 |
3.2 generate_dataset 的滑窗切分与样本数量推导
generate_dataset用滑动窗口把长时间序列切成监督学习样本。输入窗口num_timesteps_input=12,预测窗口num_timesteps_output=3,步长为 1。
def generate_dataset(X, num_timesteps_input, num_timesteps_output): indices = [(i, i + (num_timesteps_input + num_timesteps_output)) for i in range(X.shape[2] - (num_timesteps_input + num_timesteps_output) + 1)] features, target = [], [] for i, j in indices: features.append(X[:, :, i: i + num_timesteps_input].transpose((0, 2, 1))) target.append(X[:, 0, i + num_timesteps_input: j]) return torch.from_numpy(np.array(features)), torch.from_numpy(np.array(target))indices的个数是X.shape[2] - (12 + 3) + 1。训练集取 60%,所以时间片数是34272 * 0.6 = 20563,样本数为20563 - 15 + 1 = 20549。features里每个样本先取X[:, :, i:i+12],形状(207, 2, 12),再transpose((0, 2, 1))变成(207, 12, 2);target只取第 0 个特征通道,即速度,形状(207, 3)。最终X返回(20549, 207, 12, 2),Y返回(20549, 207, 3)。这里预测目标只有速度,特征里的另一个通道可以是流量或占有率,训练时作为输入辅助信息。
3.3 DataLoader 的 batch 组装与维度重排
batch_size=50,每个 batch 的X是(50, 207, 12, 2),Y是(50, 207, 3)。PyTorch 的Conv2d要求输入是(N, C, H, W),所以TimeBlock内部先做X.permute(0, 3, 1, 2),变成(50, 2, 207, 12)。这里的H是节点数 207,W是时间长度 12,卷积核(1, kernel_size=3)只沿时间维滑动,不混合节点。如果保持(50, 207, 12, 2)直接卷积,通道维变成最后一个维度,卷积会沿节点和时间混合,语义完全错。
| 阶段 | 形状 | 含义 |
|---|---|---|
原始X | (207, 2, 34272) | 节点, 特征, 时间 |
训练集X | (207, 2, 20563) | 60% 时间片 |
样本X | (20549, 207, 12, 2) | 样本, 节点, 历史步, 特征 |
batchX | (50, 207, 12, 2) | batch, 节点, 历史步, 特征 |
TimeBlock输入 | (50, 2, 207, 12) | batch, 通道, 节点, 时间 |
batchY | (50, 207, 3) | batch, 节点, 未来 3 步 |
提示:把
num_timesteps_input和num_timesteps_output写进配置文件,不要散落在函数调用里。后面改预测窗口时,generate_dataset、STGCN的Linear输入、评估反变换要一起改,漏掉一个就会形状不匹配。
4. STGCN 三明治结构:TimeBlock 门控卷积、Theta 空间变换与 STGCNBlock 前向
4.1 GLU 与一维因果卷积:TimeBlock 为什么不用 RNN
STGCN 的时间模块叫TimeBlock,核心是三个Conv2d,对每个节点独立做一维时间卷积。论文用 GLU 实现门控,源码里的写法是conv1作为主体,conv2经过sigmoid做门,conv3再参与残差式相加。
class TimeBlock(nn.Module): def __init__(self, in_channels, out_channels, kernel_size=3): super(TimeBlock, self).__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, (1, kernel_size)) self.conv2 = nn.Conv2d(in_channels, out_channels, (1, kernel_size)) self.conv3 = nn.Conv2d(in_channels, out_channels, (1, kernel_size)) def forward(self, X): # X: (batch_size, num_nodes, num_timesteps, num_features) X = X.permute(0, 3, 1, 2) # (B, C, N, T) temp = self.conv1(X) + torch.sigmoid(self.conv2(X)) out = F.relu(temp + self.conv3(X)) out = out.permute(0, 2, 3, 1) # (B, N, T, C) return out三个卷积核形状都是(1, kernel_size),也就是说只在时间维W上滑动,节点维H不参与。kernel_size=3且没有 padding,每过一层时间长度减少kernel_size - 1 = 2。conv2的sigmoid输出在 0 到 1 之间,相当于给conv1的结果做门控,conv3再补一条通路,F.relu保留非线性。与 RNN 相比,这种结构不依赖上一步输出,整个时间窗内的卷积可以并行算,训练速度优势就来自这里。
4.2 Theta 参数矩阵与 einsum 实现图卷积
STGCNBlock把时间卷积、空间图卷积、再一个时间卷积串成三明治。空间部分没有用nn.Conv2d,而是一个可学习的Theta1参数矩阵,配合A_hat做节点间聚合。
class STGCNBlock(nn.Module): def __init__(self, in_channels, spatial_channels, out_channels, num_nodes): super(STGCNBlock, self).__init__() self.temporal1 = TimeBlock(in_channels=in_channels, out_channels=out_channels) self.Theta1 = nn.Parameter(torch.FloatTensor(out_channels, spatial_channels)) self.temporal2 = TimeBlock(in_channels=spatial_channels, out_channels=out_channels) self.batch_norm = nn.BatchNorm2d(num_nodes) self.reset_parameters() def reset_parameters(self): stdv = 1. / math.sqrt(self.Theta1.shape[1]) self.Theta1.data.uniform_(-stdv, stdv) def forward(self, X, A_hat): t = self.temporal1(X) lfs = torch.einsum("ij,jklm->kilm", [A_hat, t.permute(1, 0, 2, 3)]) t2 = F.relu(torch.matmul(lfs, self.Theta1)) t3 = self.temporal2(t2) return self.batch_norm(t3)Theta1形状(out_channels, spatial_channels),初始化用均匀分布,范围由1 / sqrt(spatial_channels)决定。einsum完成节点维聚合,matmul完成通道变换,F.relu增加非线性。通道数从out_channels=64压到spatial_channels=16,再恢复到 64,这是瓶颈策略:空间变换的参数量从64*64降到64*16,同时迫使模型在低维空间里保留关键空间模式。BatchNorm2d(num_nodes)把节点数当作通道数,训练时按节点维统计均值和方差;如果自己改网络结构,建议先permute成(B, C, N, T)再 BN,或者换成LayerNorm,否则容易在 batch 较小时出现统计抖动。
4.3 两个 STGCNBlock 加输出层的维度推演
整体STGCN堆两个STGCNBlock,再接一个TimeBlock和全连接层。第一个 block 的输入通道是 2,输出通道 64,空间通道 16;第二个 block 输入 64,输出 64,空间通道 16;最后的TimeBlock把通道统一到 64,再展平时间维和通道维。
class STGCN(nn.Module): def __init__(self, num_nodes, num_features, num_timesteps_input, num_timesteps_output): super(STGCN, self).__init__() self.block1 = STGCNBlock(in_channels=num_features, out_channels=64, spatial_channels=16, num_nodes=num_nodes) self.block2 = STGCNBlock(in_channels=64, out_channels=64, spatial_channels=16, num_nodes=num_nodes) self.last_temporal = TimeBlock(in_channels=64, out_channels=64) self.fully = nn.Linear((num_timesteps_input - 2 * 5) * 64, num_timesteps_output) def forward(self, A_hat, X): out1 = self.block1(X, A_hat) out2 = self.block2(out1, A_hat) out3 = self.last_temporal(out2) out4 = self.fully(out3.reshape((out3.shape[0], out3.shape[1], -1))) return out4以batch_size=50、num_timesteps_input=12、num_timesteps_output=3为例,维度变化如下表。每个STGCNBlock里有两个TimeBlock,每个减少 2,所以一个 block 时间维减少 4;两个 block 减少 8;最后last_temporal再减少 2,总减少 10,剩下 2。
| 模块 | 输入 | 输出 | 时间维变化 |
|---|---|---|---|
block1的temporal1 | (50,207,12,2) | (50,207,10,64) | 12 → 10 |
block1的Theta1 | (50,207,10,64) | (50,207,10,16) | 通道 64 → 16 |
block1的temporal2 | (50,207,10,16) | (50,207,8,64) | 10 → 8 |
block2整体 | (50,207,8,64) | (50,207,4,64) | 8 → 4 |
last_temporal | (50,207,4,64) | (50,207,2,64) | 4 → 2 |
reshape | (50,207,2,64) | (50,207,128) | 展平时间与通道 |
fully | (50,207,128) | (50,207,3) | 输出未来 3 步 |
Linear输入写的是(num_timesteps_input - 2 * 5) * 64,其中2 * 5对应 5 个TimeBlock各减少 2。out3.reshape((out3.shape[0], out3.shape[1], -1))把(B, N, 2, 64)展平成(B, N, 128),再对每个节点做全连接,输出(B, N, 3)。损失用 L2,训练时直接对归一化后的速度做回归。论文强调纯卷积参数少、训练快,这个结构里最耗参的其实就是几个Conv2d和最后的Linear,没有 RNN 的循环权重。
5. 训练与收敛排查:kernel_size=3 的边界、BN 顺序和全连接层形状匹配
5.1 从 W 维度的减少量反推 Linear 输入
全连接层的输入维度不是拍脑袋写的,它由时间维减少量决定。源码里Linear((num_timesteps_input - 2 * 5) * 64, num_timesteps_output)只在kernel_size=3时成立。5 个TimeBlock分别是两个STGCNBlock里的 4 个,加上最后 1 个,每个减少kernel_size - 1。把公式写清楚:
num_timesteps_input = 12 kernel_size = 3 num_time_blocks = 5 reduction = num_time_blocks * (kernel_size - 1) assert num_timesteps_input - reduction > 0 linear_in = (num_timesteps_input - reduction) * 64 print(linear_in) # 128如果改成kernel_size=5,每个 TimeBlock 减少 4,5 个共减少 20,12 步输入直接变成负数,Conv2d在时间维会先报形状错误。此时要么把num_timesteps_input增大到 20 以上,要么给时间卷积加 padding,要么减少 block 数量。我一般会先把num_time_blocks和kernel_size改成变量,训练前跑一遍形状断言,避免训练到一半才发现Linear维度对不上。
5.2 显存与批大小调整的取舍
batch_size=50在 METR-LA 上能跑,但换到节点更多的 PeMSD7(M) 或更长历史窗口时容易爆显存。中间激活最大的一块来自block1的第一个时间卷积,形状(50, 64, 207, 10),约 660 万个浮点数,单精度正向激活约 26MB,反向还要再存一份。如果 OOM,优先降batch_size,再降spatial_channels,最后才动num_timesteps_input,因为历史窗口一变,全连接层输入也要重算。
| 调整项 | 影响 | 建议 |
|---|---|---|
batch_size50 → 16 | 显存线性下降,BN 统计更抖 | 先固定随机种子对比 loss |
spatial_channels16 → 8 | 参数和激活下降 | 精度可能掉 1% 到 3% |
kernel_size3 → 2 | 每个 TimeBlock 只减 1 | 全连接输入从 128 变 448 |
num_timesteps_input12 → 24 | 历史更长,显存上升 | 注意 W 减少后仍为正 |
注意:
BatchNorm2d(num_nodes)在 batch 较小时统计量不稳定,loss 抖动不一定来自学习率。先把batch_size拉回 32 以上,或者把 BN 换成LayerNorm,再看曲线。
5.3 损失曲线抖动时先查归一化反变换
训练在 Z-score 空间里算 L2,评估时必须反变换回速度单位,否则 RMSE 看起来很小但不可解释。反变换用保存的means和stds:
def inverse_transform(X, means, stds): # X: (B, N, T) 或 (B, N, T, C) return X * stds.reshape(1, -1, 1) + means.reshape(1, -1, 1)如果只预测速度通道,取stds[0]和means[0]即可;如果多通道一起预测,形状要对齐到(1, C, 1)。排查 loss 抖动时,先拿 200 个样本过拟合:关掉 shuffle,把batch_size设为 16,训练几十个 epoch,如果训练 loss 都降不下去,问题通常在A_hat的归一化或Theta1初始化;如果训练 loss 降了但验证 RMSE 反变换后异常大,检查stds是否接近零、有没有把特征通道取错。先用小 batch 过拟合 200 个样本,验证全连接层 reshape 是否对齐,再放开全量训练。
本文还有配套的精品资源,点击获取