简介:这是一份基于时空图神经网络的交通预测PDF资料,适合智慧城市、交通数据分析与深度学习研究者阅读。资料以阿里巴巴达摩院城市大脑实践为背景,系统梳理了从数据接入、数据挖掘、预测到干预的动态闭环,重点讲解如何利用时空图神经网络处理交通流量、事故与天气数据,构建短长期交通流预测模型,并涉及低延时高并发计算及开源平台等工程落地问题。资源为单个PDF文档,大小约1.48MB,内容紧凑完整,可作为技术报告或行业综述快速查阅。目前已有292人学习/浏览,是快速了解城市大脑交通预测技术框架与模型思路的入门参考。
1. 先搞清楚这张 PDF 在解决什么问题
假设你在做某城市主干道未来 15 分钟平均车速预测。LSTM 跑出来的 baseline 在白天还能看,一到早高峰就出问题:A 路预测 60 km/h,但它的上游 B 路已经堵成深红,这个结果显然离谱——因为你把每条路段当独立时间序列了。路网是图,车辆会流动,堵车会传递,结构信息一旦被丢掉,再强的时序模型也无从补救。基于时空图神经网络的交通预测,就是要把“结构 + 时序”放进同一个模型:路段是图上节点,连接是边,节点特征随时间变化,图卷积抽空间依赖,时序模块抽时间依赖。这份 PDF 的核心是把两件事联合建模,而不是分别在两个模型里做完再拼结果。适合谁:手里有路网拓扑或卡口数据,想摆脱“拆开预测”的算法工程师,以及做智慧交通方案选型的技术负责人。
2. 把路网变成图数据:原始数据到模型输入的三步预处理
很多人拿到 PDF 第一件事是抄模型结构,结果代码抄得一模一样,训练出来却是一团糟。问题几乎都出在数据侧:路网和检测器数据不是天生就能喂给图神经网络的。在模型跑起来之前,你要完成三件事:构图、建特征、切样本。
2.1 邻接矩阵的三种构造法与关键参数
STGNN 的输入图一般用邻接矩阵 A 描述,A[i][j] 表示节点 i 和节点 j 之间的连接权重。同一个路网,至少有三套常见构图法:基于空间距离、基于路网拓扑、基于历史相关性。
先说我用得最多的空间距离法。把每个检测器或路段取一个经纬度坐标点,按两两之间的欧氏距离构造高斯核函数。这套做法不依赖路网拓扑数据,只要有点位坐标就能建图;缺点是当两个检测器物理上很近但被高架或河流隔开时,拓扑上并不相通,空间距离会制造一条虚边。所以实际工程里一定要加阈值,把权重太小的边直接去掉。
基于路网拓扑的构图法更贴近真实世界,做法是把路口或路段当作节点,用道路连接关系直接生成 0/1 邻接矩阵。问题是很多检测器点位不在路口,需要先匹配到最近节点,矩阵很容易变稀疏,而且部分节点可能完全孤立,图卷积在孤立节点上只能学到自身特征。
基于历史相关性的做法更“数据驱动”,用速度或流量序列的相关系数当边权,例如 A[i][j] = |Pearson(i, j)|。它能捕捉真正同步变化的道路对,但前提是每条序列足够长、质量足够好,而且相关系数很容易受节假日和异常事件污染,训练时容易过拟合。
我的建议是第一版用空间距离法跑通链路,再融合拓扑掩码和历史相关性做迭代。下面是最常用的距离阈值高斯核:
import numpy as np from scipy.spatial.distance import cdist def build_adj_from_coords(coords, sigma2=0.1, threshold=0.7): """ coords: (N, 2) 数组,每一行是节点的 (经度, 纬度) 返回: (N, N) float32 邻接矩阵 """ n = len(coords) dist = cdist(coords, coords, metric='euclidean') adj = np.exp(-dist ** 2 / sigma2) adj[adj < threshold] = 0.0 np.fill_diagonal(adj, 0.0) # 自环在 GCN 里单独加 return adj.astype(np.float32)sigma2 和 threshold 是两个要反复试的参数。sigma2 控制距离衰减速度,设太小则只有自环还保留权重,设太大则整个矩阵全是接近 1 的稠密连接。threshold 决定稀疏度,经验上让每行平均非零元素数落在 5 到 15 比较合适;太稀疏聚合不到邻居信息,太稠密输出会向邻居均值收敛,预测结果全部被磨平。我习惯先画一行权重分布,再看整个图的稀疏度,两个参数一起定。
如果手里有路网拓扑关系,可以在距离矩阵上乘一个连通掩码,这样一个矩阵里同时保留拓扑可达约束和空间权重:
def build_adj_with_mask(coords, topo_mask, sigma2=0.1, threshold=0.7): dist = cdist(coords, coords, metric='euclidean') adj = np.exp(-dist ** 2 / sigma2) adj = adj * topo_mask # topo_mask 是 0/1 矩阵,1 表示拓扑可达 adj[adj < threshold] = 0.0 np.fill_diagonal(adj, 0.0) return adj这里有个隐藏前提:所有节点坐标必须是同一坐标系。不同供应商的数据有的是经纬度,有的是平面投影坐标,直接混合计算距离会得到一张完全不可用的图。我见过不止一次指标比 baseline 还差,排查半天发现是坐标系混用。
2.2 节点特征:先做缺失值插值,再做归一化
邻接矩阵描述“谁和谁相连”,节点特征描述“这条路上正在发生什么”。交通预测里常用特征有三类:历史观测值(速度、流量、占有率)、时间编码(星期几、是否节假日、一天中的小时)、外部事件(天气、临时施工)。PDF 里的模型骨干一般只用第一类,但产品化时建议把第二类也拼进去。
真实数据有三类问题:缺失、噪声、量纲不一致。先处理缺失。检测器损坏、网络传输断链是常态,经常出现一整夜没有数据的横沟。短间隙用时间维度的线性插值,长间隙超过一小时不要硬插,直接把对应样本的 mask 置 0,让模型学会忽略缺失位置。
再处理量纲。速度是 0 到 120 的连续值,流量可能从 0 到 6000 辆每小时,如果不做归一化直接进模型,图卷积结果会被流量一个维度主导。速度、流量、占有率各自做 z-score 比较稳妥,输出端最后再还原。如果未来要做分位数回归,也可以统一用 min-max 压到 [0,1]。
def preprocess_features(df, feature_cols, method='zscore'): """ df: 包含 node_id, speed, flow, occupancy 的 DataFrame feature_cols: 要标准化的特征列 """ result = {} for node in df['node_id'].unique(): sub = df[df['node_id'] == node].sort_index() if method == 'zscore': mean = sub[feature_cols].mean() std = sub[feature_cols].std().replace(0, 1) sub[feature_cols] = (sub[feature_cols] - mean) / std elif method == 'minmax': lo = sub[feature_cols].min() hi = sub[feature_cols].max() sub[feature_cols] = (sub[feature_cols] - lo) / (hi - lo + 1e-6) result[node] = sub return pd.concat(result.values())这里必须强调两点。第一,标准化统计量只能在训练集上计算,验证集和测试集要用训练集的均值标准差去转换;一旦把验证集混进统计过程,验证指标会虚高,上线后立刻现原形。第二,不同节点的速度分布差异很大,高速路和城区小路的标准差可能差好几倍。按节点分别标准化通常收敛更快,但推理还原时也要按同样方式做。
提示:如果你发现训练集和验证集 MAPE 差异很小,但线上效果一塌糊涂,先查标准化统计量是不是混了验证集,这是最常见的隐性泄漏。
2.3 时间滑窗:切样本时最容易犯的错
模型输入的常见形态是:取过去 P 个时间步的图特征,预测未来 Q 个时间步。P 由预测目标定义,比如 5 分钟粒度、预测未来 15 分钟,一般取过去 6 到 12 步;预测未来 1 小时,可能需要过去 1 小时以上的上下文。
滑窗本身不复杂,复杂在“样本不能乱切”。默认流程是先按时间排序,固定步长滑动;滑出来的样本要检查有没有跨越大段缺失;切完后按时间顺序划分训练、验证、测试集,而不是随机划分。交通数据有强周期性,随机抽样会把高峰样本同时放进训练和测试,指标看起来不错,部署却完全不是那回事。
def make_sliding_samples(features, seq_len=12, pred_len=3, stride=1): """ features: (T, N, F) 已标准化的特征序列 返回样本和标签 """ samples, labels = [], [] for t in range(0, len(features) - seq_len - pred_len + 1, stride): x = features[t:t+seq_len] # (seq_len, N, F) y = features[t+seq_len:t+seq_len+pred_len, :, 0:1] # 预测速度 samples.append(x) labels.append(y) return np.stack(samples), np.stack(labels)这段代码把 T 长度序列切成若干个 (12, N, F) 输入和 (3, N, 1) 标签。注意取标签时直接取原特征矩阵里的速度列,这样不容易出现标签平移错位。更重点的问题是内存:T 是三万步、N 是三百节点、F 是三维时,原始矩阵只有 27M 浮点数,还能撑;但滑窗展开后变成几百万个 (12, 300, 3) 样本,全量 numpy 化会直接内存爆掉。常见做法是写一个 Dataset 类,只记录每个样本的起始索引,训练时才按索引切片取数,不要一开始就把所有样本堆进内存。
3. 时空图神经网络的三条主线和最小可跑骨架
数据就位后,模型要怎么选。我见过不少 STGNN 论文,落到代码上其实只有三条主线:谱域图卷积、空域图卷积、时空交替框架。理解差别之后,选型就顺理成章了。
3.1 谱域图卷积与空域图卷积,工程上该选哪条
谱域方法基于图信号处理,把图上信号变换到谱域做频谱滤波。最经典的 GCN 就是谱域思想加切比雪夫近似的结果。谱域方法数学性质清晰、实现简单,缺点也很明显:滤波器参数和具体图结构强绑定,换一张拓扑图就得重新训练,节点数量到大几千后计算成本迅速上升。
空域方法直接在节点邻居上做消息传递和聚合,每个节点的新表示由它的邻居表示加权求和得到。Graph WaveNet、STGCN 这类实际效果突出的模型基本都是空域路线。交通场景节点数量常是几百到几千,图相对稳定,谱域还够用;要是做到全市级几万节点,谱域基本撑不住。我第一次做选型时直接抄了空域 GCN,也因此避开了很多谱域实现的坑。我的建议是:第一版别纠结学术流派,用空域 GCN 或 GraphSAGE 式聚合快速验证链路,再根据效果决定是否换成更重的时空模型。
3.2 时间模块选型:从 GRU 到膨胀时间卷积
图卷积解决空间聚合,时间维度还需要专门模块。常见有两种。
第一种是循环结构。把每个时间步的图卷积结果输入 GRU 或 LSTM,由门控控制时间步之间的信息保留与遗忘。实现简单、训练稳定,但循环结构必须按时间顺序计算,推理速度慢,无法并行;预测 12 步以内还行,预测 48 步以上误差累积会非常明显。
第二种是时间卷积结构,用一维卷积或膨胀因果卷积在时间维度抽取依赖。一次可以并行处理全部时间步,训练速度快,靠膨胀系数扩大感受野,不需要堆很多层。Graph WaveNet 里的时间模块就是这么做的。凡是预测长度超过 12 步的任务,我一般优先选时间卷积,而不是循环结构。
3.3 最小可跑模型:一层 GCN 接一层 GRU
我这边最简结构:输入 (batch, time, node, feature) 先 reshape 成 (batch*time, node, feature),过一层 GCN,再 reshape 回 (batch, node, time, hidden),接一层 GRU,输出未来序列。这个骨架是用来回答“这套方案在我数据上行不行”最快的路径。
下面用 PyTorch 和 PyTorch Geometric 写最小实现:
import torch import torch.nn as nn import torch.nn.functional as F from torch_geometric.nn import GCNConv class GCNGRU(nn.Module): def __init__(self, in_dim, hidden_dim, out_dim, seq_len, pred_len, num_nodes): super().__init__() self.gcn = GCNConv(in_dim, hidden_dim) self.gru = nn.GRU(input_size=hidden_dim, hidden_size=hidden_dim, batch_first=True) self.pred = nn.Linear(hidden_dim, out_dim) self.num_nodes = num_nodes self.seq_len = seq_len self.pred_len = pred_len def forward(self, x, edge_index): """ x: (batch, seq_len, num_nodes, in_dim) edge_index: (2, E) COO 格式 """ b, t, n, f = x.shape x = x.reshape(b * t, n, f) x = self.gcn(x, edge_index) # (b*t, n, hidden) x = F.relu(x) x = x.reshape(b, t, n, -1) x = x.permute(0, 2, 1, 3) # (b, n, t, hidden) x = x.reshape(b * n, t, -1) out, _ = self.gru(x) # (b*n, t, hidden) out = out[:, -self.pred_len:, :] out = self.pred(out) # (b*n, pred_len, out_dim) out = out.reshape(b, n, self.pred_len, -1) out = out.permute(0, 2, 1, 3) # (b, pred_len, n, out_dim) return out这段代码有几个地方值得解释。
第一个是 reshape 手法:GCN 之前把 (batch, time, node, feature) 压成二维 (batch*time, node, feature),让每个时间步共享同一套图卷积参数,这是 PyG 里的标准操作。时间维度的依赖完全交给 GRU,不会被 GCN 破坏。
第二个是 GRU 输入用 (batch*node, time, hidden),这不是唯一写法,也可以换成 (batch, node, time, hidden) 然后合并维度,效果差别不大。我保留这个写法是因为显存占用更友好,而且 GRU 内部迭代的 batch 更大,训练更稳定。
第三个是输出端只取 GRU 最后 pred_len 个时间步,而不是输出整个序列再接全连接。这样预测维度只跟输出长度相关,模型在切换预测时长时更灵活,不会受 seq_len 影响。
注意:PyG 的 GCNConv 要求传 edge_index 而不是邻接矩阵 A。你可以用
from torch_geometric.utils import dense_to_sparse把 NumPy 邻接矩阵转成 COO 格式,转的时候去掉自环,GCNConv 内部会自己加。
参数上我一般先用 hidden_dim=64、GCN 一层、GRU 一层跑通。如果验证 loss 下降太慢,先加 hidden_dim,不要急着加图卷积层数;如果训练 loss 很低但验证 loss 不降,再加 Dropout 并减小 hidden_dim。第一版模型的目的是验证链路,不是追指标。
4. 训练、评估与调参:三个直接影响结果的关键决策
模型能跑只是第一步。交通预测真正耗时间的是三个决策:loss 怎么选、指标怎么对比、哪些超参数值得花时间调。
4.1 loss 选型:MAE、MAPE 还是分位数损失
绝大多数复现代码盯着 MSE 不放,但做交通预测的都知道 MSE 会被少数极端事件主导。比如一次事故造成的短时拥堵,速度从 80 跌到 10,MSE 会产生非常大梯度的损失,模型为了压掉这个样本,会把正常时段的预测整体拉低。
MAE 对极端值不敏感,不容易被突发拥堵带偏,是工程上最稳的默认选择。MAPE 是无量纲百分比,方便向业务解释,但速度接近 0 时会爆炸,需要给分母加一个小常数,或者把速度下限截到 5 km/h。如果目标是做拥堵预警、给调度部门发区间预报,我建议直接用 pinball loss 做分位数回归,预测的是一个区间而不是单点,决策价值完全不同。
def quantile_loss(y_true, y_pred, tau=0.5): """ tau=0.5 时等价于 MAE,tau=0.9 时得到高分量预测 """ err = y_true - y_pred loss = torch.where(err >= 0, tau * err, (tau - 1) * err) return loss.mean()pinball loss 的一个附加好处是它天然不鼓励模型向均值回归,长序列预测时不容易把曲线磨平。我最近一个项目正是靠这个方案让 30 分钟以上的预测保留了高峰形态。
4.2 三个必调超参数:历史窗口、隐藏维度、学习率与 batch size
第一是历史窗口长度 seq_len。窗口太短,模型看不到早高峰的起势,预测拐点会滞后;窗口太长,输入维度和计算量上升,反而引入凌晨无害噪声。经验上是 5 分钟粒度预测 15 分钟,seq_len 取 6 到 12;预测 1 小时,seq_len 取 12 到 24。这个值不要只靠验证集选,最好画不同 seq_len 下的预测曲线,重点看拐点位置拟合。
第二是隐藏维度 hidden_dim。节点数少于 500 时 64 是合理起步,节点数多就上 128。但 hidden_dim 不是越大越好,交通数据信噪比有限,翻倍很快会被过拟合吞掉。一个笨办法是跑 20 个 epoch 看训练 loss 是否明显低于验证 loss;如果是,先降 hidden_dim,别急着加正则化。
第三是学习率和 batch size 的搭配。一般先把 batch 固定到 32 或 64,学习率从 1e-3 起步;如果训练 loss 下降后期剧烈抖动,降到 1e-4 重跑。学习率调度用 cosine 衰减比 Step 衰减省心,Step 的下降点需要反复试,cosine 只要设总轮数。
4.3 评估指标和 baseline 的设置方法
交通预测最容易掉进“自嗨陷阱”:模型 MAE 看着不错,却不知道相对 baseline 提升了多少。常用 baseline 有历史平均(同一时段历史均值)、上一时刻复制(用 t 时刻值直接当 t+1 预测)、线性回归、XGBoost、LSTM。我的习惯是新模型必须先跑 LSTM 和 XGBoost 两条,把它们的指标当及格线。STGNN 如果打不过这两条,大概率不是模型问题,而是图构造或数据预处理出了问题。
下面这段代码输出三个核心指标:
def evaluate_metrics(y_true, y_pred): y_true = np.asarray(y_true).flatten() y_pred = np.asarray(y_pred).flatten() mae = np.mean(np.abs(y_true - y_pred)) rmse = np.sqrt(np.mean((y_true - y_pred) ** 2)) mape = np.mean(np.abs((y_true - y_pred) / (y_true + 1e-3))) * 100 return {'MAE': mae, 'RMSE': rmse, 'MAPE': mape}这里我要加一个容易忽略的对比条件:用 RMSE 或 MAPE 对不同模型做比较时,必须保证预测时段一致。高峰期误差普遍是非高峰期的 2 到 3 倍,如果模型 A 平均预测 12 步、模型 B 只预测 6 步,指标差异根本没有可比性。另一个关键是验证集划分必须按时间顺序:比如 60 天数据,前 42 天训练、中间 9 天验证、最后 9 天测试。随机抽样等于开卷考试,模型已经见过几乎同一天所有时段的模式,测试指标完全没有参考价值。
在一组城市级路网的示意性数据上,简单对比关系大致如下表,注意数值只用于说明相对差距和对比基准,不是某篇 PDF 的官方结果:
| 方法 | MAE(km/h) | RMSE(km/h) | MAPE(%) | 备注 |
|---|---|---|---|---|
| 历史平均 HA | 8.6 | 12.1 | 22.4 | 无结构信息 |
| LSTM | 6.9 | 10.3 | 17.8 | 全局趋势可用 |
| GCN+GRU(最小骨架) | 6.1 | 9.2 | 15.6 | 加入路网结构 |
| 带膨胀卷积的时空模型 | 5.4 | 8.0 | 13.9 | 长序列更稳 |
5. 避坑与常见问题排查:复现 STGNN 最容易翻车的四个位置
这一章专治玄学。以下四条是我在项目里真实踩过的,按“现象 → 原因 → 解决”来写。
5.1 邻接矩阵没归一化,训练一轮就 NaN
现象:训练刚开始 loss 就变成 NaN,或者预测值永远落在一个常数附近,调小学习率也没用。
原因:邻接矩阵没有做对称归一化。如果直接用原始 0/1 矩阵和特征相乘,每个节点聚合的邻居数量不同,特征整体量级会被拉偏,图卷积输出越来越大,最后溢出成 NaN。另一个常见原因是矩阵里有 NaN 值没清干净,混合进稀疏矩阵计算后直接污染梯度。
解决:用对称归一化 D^(-1/2) A D^(-1/2),或者退一步做行归一化。PyG 的 GCNConv 内部做了归一化,但如果你是自己实现图卷积,这一步不能省。另外构建邻接矩阵后加一句np.isnan(adj).any()做检查,成本极低,能省下半天排错时间。
5.2 训练 loss 下降了,验证 MAPE 却比 HA baseline 还差
现象:模型训练曲线很正常,loss 稳步下降,结果在验证集上 MAPE 比历史平均还高,怎么调参都没用。
原因:大概率是数据泄漏。标准化统计量混入了验证集,或者滑窗切样本时随机打乱了时间顺序,模型在训练时已经见过相近时间片段。还有一个常见原因是评估时段没有排除夜间低流量,夜间速度接近零,MAPE 会变成巨大值,淹没了真实的预测能力。
解决:把标准化统计量严格固定到训练集,验证集和测试集只做转换不参与统计;切分样本严格按时间顺序;评估时单独跑一遍“仅白天 6:00-22:00”和“全天”两组指标,避免夜间零值污染。这两组数字差异很大,出现异常就知道问题在哪。
5.3 预测 15 分钟还行,预测 30 分钟以上曲线全被磨平
现象:短时预测的曲线形状和真实值对得上,时间拉长到 30 分钟或 1 小时,所有预测值向均值收缩,高峰肩部消失。
原因:这是两个因素叠加。一是回归型输出天然有向均值回归的趋势,预测长度越大越明显;二是模型没有学到足够强的动态模式,时间卷积感受野不够,输出趋近于平稳估计。
解决:改用分位数损失或预测残差。残差做法是把预测目标从“绝对速度”改成“相对当前时刻的变化量”,让模型保留突变方向;感受野不够时,把时间卷积的膨胀系数从 [1,2,4,8] 做起,这个组合在 5 分钟粒度下能覆盖约 75 分钟时序上下文。改完之后重点看高峰前后各两个步长的误差是否明显下降。
5.4 图卷积感受野不足,高峰拐点系统性滞后
现象:预测速度和真实值的拐点对不上,模型预测的堵车开始时间和结束时间都晚了 10 到 15 分钟。
原因:图卷积层数太少。一层 GCN 只能看到一跳邻居,如果两个强相关路段中间隔了一个路口,信息传不过去。空间感受野不足时,模型只能靠时间序列自己猜拐点,猜不准就滞后。
解决:把图卷积从 1 层加到 2 到 3 层,但要注意加层之后参数量上升,配合 Dropout 0.3 左右;同时检查邻接矩阵最远有效传播距离,用A_power = A @ A看二阶邻居是否真的连上了。很多人加层无效是因为邻接矩阵太稀疏,二跳邻居已经全变成了零,加多少层都是白搭。
6. 验证、轻量化与进一步优化
最后说点让方案真正落地的做法。模型效果好只算完成三分之一,后端还要解决推理速度、模型体积和线上稳定性。这里三个技巧,都是我做交通预测项目时沉淀下来的。
6.1 用误差空间分布判断模型是否学到结构
前面提过 baseline,我再给一个判断模型是否学到时空结构的方法。把每个节点的预测误差展开到路网上,画一张空间分布图。如果差值在相邻节点上成片出现,说明这些节点之间的边没有起作用,模型只学到了全局趋势。正常情况下,误差应该是随机散落在各节点,而不是聚成一团。这一步肉眼看起来简单,却比任何指标都能更快暴露构图问题。
6.2 模型压缩与推理部署
交通预测经常要实时更新,真实线上服务通常不需要 GPU。GCN+GRU 这类结构可以直接把权重导出成 TorchScript 或 ONNX 格式,用 CPU 推理。常见的优化有两点:一是把 GCN 里的 D^(-1/2) A D^(-1/2) 归一化矩阵预计算成常量,避免每次 forward 重复算;二是把 GRU 的时间步剪到实际需要的最小长度,推理时按 causal 顺序只算最后几个时间步。剪完之后模型体积通常能从几十 MB 降到几 MB,单条预测延迟压到毫秒级。
6.3 定期重训与再校准
路网会变,模型不能一次训练终身使用。我的习惯是每两周用最近八周数据做一次增量微调,保留原有权重,只在新数据上继续训少量 epoch。每次重训之后,在测试集上对比新旧模型 MAPE 并记录下来。连续三次重训都没有正的提升,就停止重训,回头检查窗口长度和特征组成,而不是继续空转。这篇笔记读到最后,最想让你带走的一条经验是:时空图模型的收益必须靠 baseline 对比和误差空间分布来证明,而不是只看训练 loss。希望这些踩坑经历能帮你少走一段弯路。
本文还有配套的精品资源,点击获取