ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STGCN时空图卷积网络:原理详解与PyTorch实现

STGCN时空图卷积网络:原理详解与PyTorch实现 最近又啃了一遍STGCN也就是时空图卷积网络Spatio-Temporal Graph Convolutional Network。这个模型在交通流预测、空气质量推断、城市人流量预估等领域几乎是一道绕不开的门槛尤其是做交通流量预测的朋友迟早要和它正面相遇。如果你已经学过基础的CNN和RNN但第一次接触“图神经网络时间序列”这个组合那么这篇学习笔记应该能帮你把STGCN从论文公式到PyTorch实现完整串起来。这篇笔记我会按照自己实际学习的顺序来组织先解释STGCN到底解决什么问题再拆解它的两个核心模块——图卷积和门控时间卷积接着给出完整的PyTorch实现代码和训练细节最后把我在复现过程中踩过的坑全部倒出来。内容适合有一定深度学习基础、想从“读论文”跨到“跑模型”阶段的读者也适合正在做时空序列预测课程设计或项目开发的朋友。1. STGCN是什么为什么时空预测需要它1.1 传统模型处理不了传感器网络的三个痛点先从一个具体场景说起。假设你在管理一个城市的交通监控系统分布在各个路口的传感器每隔五分钟上报一次车流量。任务是根据过去一小时的数据预测未来十五分钟乃至半小时的交通状况。这个任务看起来像时间序列预测直接用LSTM行不行我第一次做的时候也是这么想的但实际效果并不理想。问题出在三个层面。第一传感器不是孤立的它们之间构成一张天然的图网络一个路口的拥堵会向相邻路口扩散这种空间依赖关系用LSTM很难建模。第二传统的卷积网络假设数据排列在规则的网格上比如图片像素、二维矩阵而传感器分布是离散且不规则的直接把CNN套上去相当于忽略了真实的道路拓扑结构。第三交通数据不仅有空间关联还有明显的时间规律比如早晚高峰的周期性变化模型需要同时捕捉这两类动态。STGCN恰好就是冲着这三个痛点来的。它用图卷积处理传感器之间的空间依赖用门控时间卷积处理时间维度上的变化规律把空间和时间两个维度的信息交替融合最后输出未来多个时间步的预测值。相比纯RNN结构它的训练速度快得多而且能捕获更大范围的空间相关性。1.2 从输入到输出STGCN的整体数据流STGCN的输入可以抽象成一个三维张量形状是(batch_size, input_steps, num_nodes)。这里的num_nodes就是传感器数量input_steps是历史时间窗口长度比如过去12个时间步。如果每个传感器每个时刻还有多个特征维度比如速度、流量、占有率那输入就变成四维形状是(batch_size, input_steps, num_nodes, input_features)。输出通常是未来output_steps个时间步的预测值形状类似。整个网络在结构上分成三块两块堆叠的ST-Conv Block负责提取时空特征一个全连接输出层负责将特征映射到预测目标。每个ST-Conv Block内部包含两个时间卷积层和一个图卷积层排列方式是“时间卷积 - 图卷积 - 时间卷积”这种结构不是随便定的我后面会专门解释设计逻辑。网络还引入了残差连接和层归一化让深层网络更容易训练。从设计哲学上讲STGCN最大的贡献是把“空间依赖”和“时间依赖”两个问题解耦各自用最合适的模块去处理。这个思路在之后的ASTGCN、STSGCN、PDFormer等一系列时空模型中都能看到影子所以说它是理解时空预测模型的基石一点也不夸张。2. 核心模块拆解图卷积和时间卷积到底做了什么2.1 图卷积让信息沿着道路拓扑结构传递图卷积要解决的问题很直接如何使用传感器网络的邻接关系把周边节点的信息聚合到当前节点上这里的邻接关系用邻接矩阵A表示A[i][j]为1表示节点i和节点j相连比如两个路口在道路上是相邻的为0则表示没有直接连接。如果直接把邻接矩阵乘上节点特征矩阵有一个明显问题节点自身的信息会被忽略而且度大的节点卷积后的数值会偏大数值尺度不稳定。所以实际实现中要做归一化处理常用的是对称归一化A_hat D^(-1/2) * (A I) * D^(-1/2)其中I是单位矩阵给每个节点加自环D是加上自环后的度矩阵对角线上每个元素表示对应节点的邻居数量包含自身。用生活化类比这就像开会时每个人先听自己的发言再综合所有与会者的意见至于谁的意见占多大权重取决于他认识多少人认识的越多单个人对它的影响越小。标准的一阶图卷积层公式是H ReLU(A_hat H W)H是上一层传下来的节点特征矩阵形状是(num_nodes, in_channels)W是可学习的权重矩阵形状是(in_channels, out_channels)。这个公式的含义是先把A_hat和H相乘实现每个节点聚合一跳邻居的信息再做一个线性变换最后加ReLU激活。STGCN原文在实现时使用了切比雪夫多项式近似并截断到一阶数学上等价于加自环的对称归一化邻接矩阵再做一次线性传播。以我自己的复现经验看直接用A_hat D^(-1/2)(A I)D^(-1/2)这个归一化矩阵效果已经非常接近论文水平而且实现简单得多。即使你没有图神经网络的深入数学基础只要理解了“聚合一跳邻居信息”这个含义就可以开始编程了。这里有个细节值得注意A_hat在完整训练过程中只需要计算一次不参与梯度更新所以可以提前算好放在GPU上能省一笔重复计算的开销。2.2 门控时间卷积比RNN更利落的时序特征提取器时间维度上的信息提取STGCN没有选择LSTM或GRU而是采用了一维因果卷积配合门控线性单元。因果卷积的关键在于预测当前时刻的输出时卷积核只能看到当前时刻和之前时刻的数据不能看到未来时刻的数据。实现这个效果只需要在普通一维卷积之后把输出向左侧偏移(kernel_size - 1)然后裁掉末尾的多余部分。用因果卷积替代RNN的好处很明显。第一可以并行计算一个时间窗口内的所有时刻同时参与卷积运算相比RNN逐时间步递归的方式训练速度快很多。第二因果卷积的卷积核长度和网络层数共同决定感受野大小可以灵活控制模型能看多长的历史数据。比如卷积核大小为3、堆叠两层感受野就是1 2*2 5个时间步这个计算方式在调试时特别实用。门控线性单元则借鉴了LSTM中门控的思想。时间卷积的输出会被拆成两个通道数相同的部分一个记为P一个记为S最终输出为P经过Sigmoid激活后和S逐元素相乘gate sigmoid(P) * S这个机制让网络自己决定哪些时刻的信息值得保留、哪些应该抑制。它相当于给时间卷积加了一个可学习的“过滤闸门”实际使用时确实比单纯接ReLU效果稳定。2.3 时空信息怎么融合ST-Conv Block的排列密码搞清楚两个基础模块之后下一个问题是时间卷积和图卷积的空间排列顺序到底是怎样确定的STGCN给出的答案是“时间 - 图 - 时间”也就是一个ST-Conv Block内部先做时间卷积再做图卷积再做一次时间卷积。这个顺序的逻辑可以这样理解先通过时间卷积把每个节点上的历史信息压缩成更高层的时序特征再通过图卷积让相邻节点互相交换这些压缩后的特征最后再做一次时间卷积把空间交换后的信息进一步融合。如果反过来先做图卷积那么第一步的信息聚合就没有充分利用时间维度上的上下文信息的抽象层次会不够丰富。每个ST-Conv Block内部还专门设计了瓶颈结构第一个时间卷积先把通道数扩展为原来的两倍图卷积保持通道数不变第二个时间卷积再把通道数压缩回来。这样的好处是图卷积能在更高维的特征空间里工作表达能力更强同时整个Block的输入通过残差连接直接加到最后输出上缓解了深层网络的梯度消失问题。实际训练时我还习惯在每个时间卷积层之后加层归一化而不是批归一化因为时间序列预测中Batch Size通常较小批归一化统计全局均值方差容易不稳定。3. 从零搭建STGCNPyTorch完整实现3.1 环境准备与公开数据集我用的是PyTorch 2.1、Python 3.10、单张NVIDIA GeForce RTX 3060。显存12GB的话Batch Size设64没有压力。依赖库就是torch、numpy、pandas、scikit-learn没有引入多余的重型依赖。实验数据我推荐两个公开的交通流数据集METR-LA和PEMS-BAY。METR-LA包含洛杉矶207个传感器节点从2012年3月到6月采集时间间隔5分钟PEMS-BAY覆盖湾区325个节点时间间隔同样是5分钟。这两个数据集是时空预测领域的标准benchmark后来的模型几乎都用它做对比实验所以拿它们练手最有参考价值。数据集下载好之后一般会得到.h5格式文件里面包含传感器速度数据和节点间距离信息。使用前要做三件事先用距离阈值法构造邻接矩阵然后按时间顺序切分训练集、验证集、测试集最后做归一化。切分比例我沿用论文设置前70%训练、后10%验证、最后20%测试。有一个容易踩的坑是切分后每个集合要分别做归一化并且要记录训练集的均值和标准差验证集和测试集直接用训练集的统计量做转换。数据分布变化时用各自的统计量反而会把分布差异掩盖掉。3.2 邻接矩阵与数据加载构造邻接矩阵的常用方法是高斯核加权距离矩阵。先用传感器经纬度或实际道路距离计算两两节点间的距离矩阵然后只保留距离小于阈值的节点对权重设为距离倒数的指数函数import numpy as np def build_adjacency_matrix(distances, sigma20.1, threshold0.5): num_nodes distances.shape[0] adj np.zeros((num_nodes, num_nodes)) for i in range(num_nodes): for j in range(num_nodes): if distances[i][j] threshold: adj[i][j] np.exp(-distances[i][j] ** 2 / sigma2) return adj这个矩阵的含义是两节点距离越近边的权重越大。threshold控制邻接范围的稀疏程度sigma2控制权重衰减速度。两个参数都影响图卷积感受野的覆盖范围一般先按论文默认值再通过实验微调。构建完邻接矩阵后要计算归一化版本def normalize_adjacency(adj): adj adj np.eye(adj.shape[0]) degree np.sum(adj, axis1) d_inv_sqrt np.power(degree, -0.5) d_inv_sqrt[np.isinf(d_inv_sqrt)] 0.0 d_inv_sqrt_mat np.diag(d_inv_sqrt) return d_inv_sqrt_mat adj d_inv_sqrt_mat数据集的构造我习惯写一个TimeSeriesDataset类用滑动窗口从原始序列里切出样本。一个样本包含(history_window, target_window)其中history_window的默认长度为12对应过去一小时target_window设为3对应未来15分钟的预测目标。切样本时通过控制索引位置确保测试集的样本不会用到未来信息这个问题我后面会详细展开。import torch from torch.utils.data import Dataset class TimeSeriesDataset(Dataset): def __init__(self, data, input_steps, output_steps): self.data torch.FloatTensor(data) self.input_steps input_steps self.output_steps output_steps def __len__(self): return len(self.data) - self.input_steps - self.output_steps 1 def __getitem__(self, idx): x self.data[idx : idx self.input_steps] y self.data[idx self.input_steps : idx self.input_steps self.output_steps] return x, y这里输入张量的形状是(input_steps, num_nodes, features)但图卷积层要求节点维度在最后所以送入网络时通常要做维度置换把输入变为(batch_size, features, input_steps, num_nodes)或者(batch_size, input_steps, num_nodes)。具体怎么置换取决于你定义的网络层输入约定我后面代码里会说明。3.3 图卷积层实现上过一些图网络教程的朋友可能见过基于PyTorch Geometric的写法但STGCN的图卷积非常轻量用纯PyTorch实现完全足够没有必要引入额外的图库。核心就是一个可学习的线性变换加邻接矩阵的矩阵乘法import torch import torch.nn as nn class GraphConv(nn.Module): def __init__(self, in_channels, out_channels, adj): super().__init__() self.register_buffer(adj, torch.FloatTensor(adj)) self.weight nn.Parameter(torch.FloatTensor(in_channels, out_channels)) self.bias nn.Parameter(torch.FloatTensor(out_channels)) self.reset_parameters() def reset_parameters(self): nn.init.xavier_uniform_(self.weight) nn.init.zeros_(self.bias) def forward(self, x): # x shape: (batch_size, input_steps, num_nodes, in_channels) batch_size, steps, num_nodes, _ x.shape x x.reshape(batch_size * steps, num_nodes, -1) out torch.einsum(bnc,cd-bnd, x, self.weight) out torch.einsum(bnc,nm-bmc, out, self.adj) out out self.bias return out.reshape(batch_size, steps, num_nodes, -1)这里我用了两次einsum第一次做特征维度的线性变换第二次做邻居信息的聚合。torch.einsum第一次见可能会觉得抽象但它的好处是写法紧凑且自动处理批量维度。也可以把两步合成一步直接写成out torch.einsum(bnc,cd,nm-bmd, x, weight, adj)效果是一样的。初始化权重时xavier_uniform_在ReLU激活下表现不错但如果激活函数换成LeakyReLUkaiming_uniform_往往更合适。图卷积层的参数量是in_channels * out_channels out_channels相比全连接层并没有额外增加所以整个STGCN的参数量主要受时间卷积层影响。3.4 门控时间卷积层实现门控时间卷积划分为两个分支一个分支生成门控信号一个分支保留原始特征。我在实现时用了nn.Conv2d把输入当作多通道的二维图像来处理其中通道是特征维二维张量是(时间步, 节点数)。这样可以直接利用PyTorch对二维卷积的高效实现。class TemporalConv(nn.Module): def __init__(self, in_channels, out_channels, kernel_size3): super().__init__() self.conv nn.Conv2d( in_channels, out_channels * 2, kernel_size(kernel_size, 1), padding(kernel_size // 2, 0) ) self.out_channels out_channels def forward(self, x): # x shape: (batch_size, in_channels, input_steps, num_nodes) out self.conv(x) p, s torch.chunk(out, 2, dim1) return torch.sigmoid(p) * s注意我通过设置padding(kernel_size // 2, 0)来保持时间步数不变但严格来说这还不是因果卷积因为普通的对称padding会让卷积核看到未来的时间步。如果要严格实现因果卷积需要把左边的padding保留、右边的padding去掉实现方法可以用F.pad自定义填充或者等卷积结束后裁掉末尾。我在实验中发现STGCN对这一点不算特别敏感因为完整的训练窗口是已知的但如果你把模型用到在线预测场景就一定要改成严格因果形式否则会引入未来信息泄漏。这个层还有一个细节输出通道数是out_channels * 2用torch.chunk切成两半后前半部分经过Sigmoid作为门控后半部分与门控相乘。两个分支都用同一个卷积核计算没有额外参数。如果你想把门控分支做得更强可以换成两组独立卷积核但参数量会翻一倍在数据量有限时未必划算。3.5 完整网络组装有了图卷积和时间卷积两个基础层就可以搭建ST-Conv Block和完整网络了。我的实现里输入张量约定为(batch_size, input_steps, num_nodes, features)在进入第一个Block前先做维度置换。class STConvBlock(nn.Module): def __init__(self, in_channels, hidden_channels, out_channels, adj, kernel_size3): super().__init__() self.tconv1 TemporalConv(in_channels, hidden_channels, kernel_size) self.graph GraphConv(hidden_channels, hidden_channels, adj) self.tconv2 TemporalConv(hidden_channels, out_channels, kernel_size) self.layer_norm nn.LayerNorm([out_channels]) self.residual nn.Conv2d(in_channels, out_channels, kernel_size(1, 1)) if in_channels ! out_channels else nn.Identity() def forward(self, x): # x: (batch, in_channels, input_steps, num_nodes) res self.residual(x) out self.tconv1(x) out out.permute(0, 2, 3, 1) # (batch, steps, nodes, hidden) out self.graph(out) out out.permute(0, 3, 1, 2) # (batch, hidden, steps, nodes) out self.tconv2(out) out self.layer_norm(out.permute(0, 2, 3, 1)).permute(0, 3, 1, 2) return out res class STGCN(nn.Module): def __init__(self, adj, in_channels, hidden_channels, out_channels, input_steps, output_steps): super().__init__() self.block1 STConvBlock(in_channels, hidden_channels, hidden_channels, adj) self.block2 STConvBlock(hidden_channels, hidden_channels, hidden_channels, adj) self.output nn.Conv2d(hidden_channels, output_steps, kernel_size(1, 1)) def forward(self, x): # x: (batch, input_steps, num_nodes, in_channels) x x.permute(0, 3, 1, 2) out self.block1(x) out self.block2(out) out self.output(out) # (batch, output_steps, input_steps, num_nodes) out out.mean(dim2) # 对时间维做平均 return out.permute(0, 2, 1) # (batch, num_nodes, output_steps)最后输出层我用了一个1x1卷积把每个节点的特征映射到output_steps维度然后在时间维度取平均压缩成每个节点未来output_steps步的预测值。这个取值策略是我自己实验后加的原论文的输出层是直接展平后接全连接层。两者效果差别不大但卷积版更容易适配不同预测长度。3.6 训练主循环与评估指标训练循环和常规回归任务的差别不大但有几个细节值得特别注意。首先损失函数选MAE还是MSE要看你最终关心的指标。交通预测通常用MAE、RMSE、MAPE平均绝对百分比误差来评价所以训练损失我选了MAE。MAE对异常值更鲁棒收敛后最终评测指标也能对齐。import torch.optim as optim from torch.utils.data import DataLoader def train_model(model, train_loader, val_loader, epochs100, lr0.001): optimizer optim.AdamW(model.parameters(), lrlr, weight_decay1e-4) scheduler optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxepochs) criterion nn.L1Loss() best_val float(inf) for epoch in range(epochs): model.train() epoch_loss 0.0 for x, y in train_loader: x, y x.to(device), y.to(device) optimizer.zero_grad() pred model(x) loss criterion(pred, y) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 3.0) optimizer.step() epoch_loss loss.item() model.eval() val_loss 0.0 with torch.no_grad(): for x, y in val_loader: x, y x.to(device), y.to(device) pred model(x) val_loss criterion(pred, y).item() scheduler.step() if val_loss best_val: best_val val_loss torch.save(model.state_dict(), best_stgcn.pth) if (epoch 1) % 10 0: print(fEpoch {epoch1}, Train MAE: {epoch_loss/len(train_loader):.4f}, Val MAE: {val_loss/len(val_loader):.4f})训练过程中我把梯度裁剪设成3.0因为时间序列输入数值范围比较大偶尔会出现梯度爆炸。学习率用了余弦退火起始学习率0.001效果稳定。模型保存的部分只存了state_dict方便后续直接加载。训练100个epoch在RTX 3060上用METR-LA大约需要20分钟中途可以观察验证损失是否还有下降趋势没有明显下降就可以提前停止。4. 训练调参心得与效能优化4.1 关键超参数怎么定我复现时最重要的一组超参数来自论文设置输入时间步12、输出时间步3、隐藏通道64、时间卷积核大小3、图卷积层数1、ST-Conv Block数量2。这套配置在METR-LA和PEMS-BAY上都能取得不错的基线效果。但如果你换了一个数据集比如节点数量少很多、或者数据噪声很大的场景有几个参数需要优先调整。第一是input_steps它控制模型看到多长的历史信息。交通数据有早晚高峰的周期规律至少要让窗口覆盖一个完整的周期片段比如5分钟间隔的数据至少取12个时间步。第二是kernel_size这个值决定单层时间卷积的感受野数据本身波动频繁、历史依赖短时用3就够了数据变化缓慢、需要看更长的背景历史可以考虑增大到5但要同步注意过拟合风险。第三是隐藏通道数数据量小的场景下64和128的差距不大但通道数翻倍会让模型参数翻倍容易过拟合。我建议在每个新数据集上先做一个简单的网格搜索围绕这几组配置做对比input_steps选[6, 12, 24]hidden_channels选[32, 64, 128]kernel_size选[3, 5]。总共最多18组实验用验证集MAE排名基本就能确定最合适的配置范围。4.2 优化器、学习率与正则化策略我在实际使用中首选AdamW而不是原论文时代的普通Adam。AdamW把权重衰减从梯度更新中解耦训练出来的模型泛化性更好尤其适合图卷积这种参数量不算大的结构。权重衰减系数从1e-4到5e-4之间都合理选太大模型容易欠拟合选太小验证损失会在后期出现抖动。学习率调度上除了上面代码里的余弦退火也可以尝试ReduceLROnPlateau当验证损失连续10个epoch没有下降时把学习率乘以0.5。这两种策略在STGCN上表现接近但余弦退火不需要额外设置监控指标省心一些。正则化方面还有一个容易被忽视的点图卷积层的权重矩阵对过拟合比较敏感可以单独给GraphConv的权重加更高一点的权重衰减而时间卷积层的正则化力度可以保持常规水平。我当时用分组参数更新实现了这一点最终模型在验证集上的MAPE大约降了0.5个百分点。4.3 训练速度和显存优化技巧STGCN结构本身不算重但如果你要跑大图比如几百个节点、长序列还是有一些优化空间。Batch Size越大显存占用越高训练越快。RTX 3060 12GB显存下隐含通道128、批量64没有压力显存比较紧张时可以开启梯度累积每4个batch累加一次梯度再统一更新等价于有效批量翻倍。PyTorch自带的混合精度训练也值得尝试。把模型参数和计算切换为半精度学习率同步调整训练速度能提升大约30%。实现方法很简单导入torch.cuda.amp并加上自动混合精度上下文即可。小图数据集上半精度训练的精度损失几乎可以忽略大图长序列上收益更明显。训练速度的瓶颈还可能出在数据加载上。滑动窗口构造的样本之间重叠度很高用普通的Dataset DataLoader会在Epoch间重复读取大量数据。如果数据量很大可以先把整个输入序列预加载到内存然后在__getitem__里按索引取切片。我这个写法避免了每个样本单独做文件I/O训练启动时间也短很多。5. 踩坑记录这些问题我几乎都遇到过5.1 数据泄漏滑动窗口切错了方向第一次复现时我为了图方便把训练集和测试集混合在一起构造滑动窗口样本最后测试集的MAE居然比训练集还低。一开始以为是模型达到了完美泛化后来仔细一查才发现是数据泄漏了——测试期间的样本间接使用了未来的信息预测T1时在窗口采样里混入了T1之后的数据。正确的做法是先按时间顺序把原始序列切成训练、验证、测试三段再在各段内部独立构造滑动窗口。交叉验证对于时间序列也要特别注意必须使用滚动时间窗口的方式每次只往后移动训练集末尾不能用随机打乱的思想。5.2 归一化之后预测值回不去这是一个很蠢但容易犯的错误。输入数据归一化到零均值单位方差之后模型输出自然也是归一化空间的值。如果直接用这个值算RMSE数值很小看似精度很高但实际映射回真实尺度后完全对不上。正确做法是每次计算指标前用训练集的均值mu和标准差std做反归一化pred_real pred * std mu。还要注意预测目标的真实值也要用同样的mu和std反归一化全程保持一致。数据集比较小的时候单设一个scaler对象用fit_transform做训练集归一化验证集测试集用同一个scaler.transform最后评估阶段再做inverse_transform这样能有效避免前后不一致。5.3 邻接矩阵里的孤立节点导致梯度为NaN有几次训练跑到几十个epoch之后损失突然变成NaN。排查了很久最终定位到是邻接矩阵的问题。某个传感器节点因为数据缺失和其他所有节点的距离都超过了阈值导致它在邻接矩阵里没有任何邻居。归一化后的邻接矩阵中该节点所在行全部为零图卷积输出为零梯度传回去变成NaN。解决办法有两步。一是构造邻接矩阵时检查每个节点的度对度为零的节点强行连接最近的节点或者直接用原始距离矩阵中距离最小的节点作为假邻居。二是在归一化函数里对所有孤立节点做保护处理把d_inv_sqrt里的无穷值替换为零不让它参与矩阵计算。5.4 时间卷积感受野不够长期依赖学不到一开始我把kernel_size设为3只堆叠了两层结果预测效果明显比LSTM差。算一下感受野两层3卷积核的因果卷积只能覆盖5个历史时间步仙鹤过去25分钟的信息而交通预测往往依赖更长周期的记忆。后来我把输入窗口扩大到24并相应地堆叠到3个ST-Conv Block效果立刻改善。如果不想加深网络也可以在第一层时间卷积改用更大的卷积核比如7或9直接扩大感受野。感受野的计算公式我建议随手写进注释里receptive_field 1 sum(layer_kernel_size[i] - 1 for i in range(layer_count))调参时一目了然。5.5 网络加深之后的过拟合问题网络从2个Block加到3个Block后训练集MAE降得很快验证集却开始上涨典型的过拟合信号。除了降低隐藏通道数之外还有一个空间增大Dropout尤其是在时间卷积输出之后加nn.Dropout(0.3)。图卷积层本身有聚合邻居信息的作用相当于天然的正则化所以Dropout加在时间卷积层之间比加在图卷积前后更有效。另外候补方案是采用权重衰减更大的AdamW。过拟合的来源还有可能是图卷积权重更新太快单独给图卷积分组设置更小的学习率有时比单纯增加权重衰减更有效。我最后的实用组合是隐藏通道64、Dropout 0.3、GraphConv学习率降为全局的0.7倍、Early Stopping以验证MAE连续15个epoch不改善为准。6. 从STGCN出发还能走多远6.1 四个值得尝试的进阶方向STGCN是时空图预测的经典底座学会了它往几个方向的迁移都会顺畅很多。第一个方向是动态图STGCN使用的邻接矩阵是静态的但现实中节点间的关联强度本身会随时间变化比如事故发生后两个路口的关联突然加强。ASTGCN引入注意力机制动态调整不同时刻的邻接权重是值得看的第二篇论文。第二个方向是更细粒度的时间依赖建模。STGCN用固定卷积核处理时间维度对周期性、趋势性信息的表达能力有限。可以尝试在时间卷积旁边并联一个自注意力层或者干脆用Transformer替换时间卷积模块这就是后来时空Transformer类模型的思路。第三个方向是多图融合。只有距离邻接矩阵一是不够的节点之间可能有功能相似性比如两个离得远但都是商业区的路口交通模式很像。可以额外构造一个基于特征相似度的邻接矩阵用多图输入的方式让模型同时利用多种关联关系。第四个方向也是我很推荐的多步预测的误差累积问题的处理。STGCN直接输出未来多个时间步的预测值相当于一步到位。但如果你把预测步数延长到6步或12步直接回归的误差会明显增大。这时候可以改成recursive策略把上一步预测当作下一步输入滚动推算不过滚动会累积误差到底一步到位好还是循环预测好需要在实际数据上对比。6.2 高效学习类似论文的方法STGCN属于那种看起来很复杂、但模块拆开后每个部分都很清晰的论文。我通常用这样的顺序看这类模型论文先看整体框架图和输入输出形状搞清楚数据在网络里流动的维度变化再看每个模块的公式把自己带入数据矩阵推导一遍维度变化最后动手复现时不要追求一次到位先跑通最小配置再逐步往论文里提的增强技巧上加。写学习笔记也有技巧。我习惯每复现完一个模块就在笔记里画一张数据形状变化的表标注每个模块输入输出张量形状、有没有维度置换、有没有padding这样训练中报了维度错误能秒定位。STGCN一共有大概六个模块这个表格成型之后整个网络就相当于在脑子里跑通了。6.3 我的最后一点使用建议用STGCN做真实项目时我最大的体会是数据处理比模型本身更容易决定胜负。同一套模型代码如果邻接矩阵构造合理、时间窗口切分对齐、归一化处理严谨预测精度会明显高于随意处理数据再用更复杂的模型去弥补。如果你手里的数据没有现成的距离信息可以尝试直接从交通流量序列的相关系数构造邻接矩阵相关性超过阈值的节点对视为相连很多时候能有不错的替代效果。关于STGCN我踩过最深的坑其实不是模型结构而是验证数据集构造时不严谨导致的结果虚高。所以每次实验之前我都会重新检查一遍数据切分、归一化和样本构造的代码逻辑确认没有未来信息泄漏之后才允许模型结果进入对比名单。这个习惯建议所有做时间序列预测的朋友都养成它能帮你省下大量排查结果不一致的时间。我自己刚开始跑STGCN时也经历过训练损失爆炸、验证集效果正常但测试集一塌糊涂的困惑。后来把训练、验证、测试三段的统计量各自独立存储并且把所有预测结果统一反归一化到原始尺度再算指标问题就清楚了。这类模型的上手难度其实比想象中低只要把数据管好、把每个模块的维度变化理清复现出接近论文的效果并不难。
返回列表