拓冰建站拓冰建站
首页 / 资讯中心 / 正文

时空图神经网络交通预测实战:从邻接矩阵构建到模型部署

简介这是一份关于基于时空图神经网络的交通预测技术总结资料面向交通大数据、智慧城市及深度学习应用方向的开发者、研究者与相关专业学生。内容围绕城市大脑框架梳理从数据接入、数据挖掘、预测干预到动态调整的完整链路并阐述时空图神经网络如何处理流量、事故、天气等时空数据从而预判交通流变化。资源为单份PDF文档共1页压缩包大小仅1.48MB便于快速阅读与分享。该文档整理自达摩院城市大脑实验室技术演讲适合希望了解时空图建模在真实交通场景中落地思路的读者也可作为交通流预测模型选型与系统设计的参考材料。目前已有292人浏览学习对于关注AI交通治理的入门者与工程师均具备不错的启发价值。1. 时空图神经网络正在重写交通预测的旧基线早高峰的某条城市快速路上一辆车突然急刹三分钟后后方 2 公里开始拥堵十分钟后隔壁平行路段流量也开始异常。这种“空间上扩散、时间上延迟”的传播过程几乎不可能靠单一道路的时间序列模型准确预测——因为交通路网天然是一张图上下游路段、出入口匝道、相邻路口之间存在强空间依赖。时空图神经网络ST-GNN正是把路网建模成图用图卷积或图注意力捕获空间关系再叠加 GRU、TCN、Transformer 等时间模块捕获时序依赖。相比传统 ARIMA、SVR 和纯 LSTM它能在短时交通预测、路况补全和信号控制等任务上明显压低误差。这篇文章按我自己落地这套方案的顺序先讲图结构怎么建再讲模型骨架怎么搭接着讲训练评估容易踩的坑最后说上线前三个不可忽略的工程点。2. 交通预测的第一步把路网变成图并构造时空特征很多人在跑模型前只准备了一份流量表时间戳、路段 ID、速度或流量。但时空图神经网络的输入不是这种长表而是两个张量节点特征矩阵和邻接矩阵。只有把路网结构转成矩阵图卷积才有地方“卷”。2.1 邻接矩阵从地理距离到语义相似度的三层做法我最先用的是地理距离阈值法。假设每个检测器有经纬度坐标两两计算欧氏距离当距离小于某个阈值时用高斯核计算边的权重。核心代码可以写成下面这样import numpy as np from scipy.sparse import csr_matrix def build_adj_by_distance(coords, sigma0.1, threshold0.5): n len(coords) A np.zeros((n, n), dtypenp.float32) for i in range(n): for j in range(n): if i j: continue dist np.linalg.norm(coords[i] - coords[j]) if dist threshold: A[i, j] np.exp(-(dist ** 2) / (sigma ** 2)) # 对称化并做行归一化 A (A A.T) / 2 A A / (A.sum(axis1, keepdimsTrue) 1e-8) return csr_matrix(A)这段代码的逻辑是坐标数组的每一行是一个检测器的经纬度threshold 控制“多远算邻居”sigma 控制权重衰减速度。距离越近权重越大越接近 1距离越远权重越小趋近 0。对称化是为了保证图是无向的行归一化是为了让后续图卷积的邻接矩阵具备较好的数值稳定性。sigma 如果取太大所有邻居权重都接近 1空间差异就被抹掉了取太小又只有极近的节点才有联系。我的经验是先看路网检测器的平均间距把 threshold 设为平均间距的 1.52 倍再在验证集上微调 sigma。第二种常见做法是 K 近邻。每个节点只与距离最近的 K 个节点相连权重仍由高斯核计算。它的好处是保证图中每个节点的度不会相差太大避免某些偏僻检测器成为孤岛。第三种做法是动态相似度不再依赖经纬度而是用节点之间历史流量序列的皮尔逊相关系数作为边权重。这种方法能捕捉“空间不相邻但流量很像”的路段比如平行的替代道路但缺点是计算成本高而且相关系数会随季节变化需要定期重算。2.2 时间特征构造滑动窗口、预测步长与样本生成图结构解决的是“谁影响谁”的问题时间维还要解决“看多长历史、预测多远未来”。我一般把原始数据按 5 分钟一个采样点重采样用过去 12 个时间步预测未来 12 个时间步也就是「看 1 小时预测 1 小时」。构造样本的代码要点如下def make_samples(flow, seq_len12, horizon12, step1): # flow: [T, N]T 为时间步数N 为节点数 X, Y [], [] for i in range(0, len(flow) - seq_len - horizon 1, step): x flow[i: i seq_len] # [seq_len, N] y flow[i seq_len: i seq_len horizon] # [horizon, N] X.append(x) Y.append(y) return np.stack(X), np.stack(Y)这里有一个容易忽略的点step 取 1 会让相邻样本高度重叠造成训练集冗余、训练变慢但也能让模型充分学习平滑过渡。如果数据量大我一般取 stepseq_len让训练样本不重叠相当于每 1 小时取一个样本如果数据量小取 step1 做数据增强。X 的形状是 [样本数, 12, 节点数]还没有特征维。如果每个节点除了速度还有流量和占用率就需要把 X 扩成 [样本数, 12, 节点数, 特征数]。2.3 三种邻接矩阵构造方式对比实际选型时我会把三种做法放在一张表里看边界构造方式空间语义适合场景主要风险距离阈值高斯核地理邻近快速路、均匀布置检测器的场景检测器稀疏时容易产生孤立点K近邻局部分布路网密度不均匀的城市路网K 太大引入无关邻居动态相似度流量模式相近平行道路、替代路线明显的区域需要定期重算且相关性不代表因果如果做多节点城市级预测我通常先用距离阈值再把孤立节点并入最近的 K 近邻节点保证每个节点至少有一条入边。如果发现模型在少数路段的误差显著高于其他路段优先检查该节点是否在图中度过低。3. 模型骨架怎么搭从图卷积到时空注意力的实现取舍图结构建好后下一步是选模型。最近几年 STGCN、ASTGCN、GWN、ST-Transformer 这些名字背后都是同一个思路先把路网图上的空间信息融合进每个时间步的特征再对时间维做序列建模。这里不背论文只讲我实际搭过、并且能跑通的方案。3.1 为什么用图卷积而非普通 CNN普通 2D CNN 处理路网网格化数据靠的是固定大小的卷积核只能捕捉矩形区域内的邻域。但路网不是矩形网格一条匝道在空间上可能和三条不同方向的道路相连如果强行把经纬度映射成像素非邻居位置会被当成邻居产生大量无效计算。图卷积的卷积对象是邻接矩阵定义的局部邻域每个节点的感受野随层数扩大因此更贴合路网的拓扑结构。最常见的图卷积层写法是import torch import torch.nn as nn import torch.nn.functional as F class GCNLayer(nn.Module): def __init__(self, in_dim, out_dim): super().__init__() self.linear nn.Linear(in_dim, out_dim) def forward(self, x, adj): # x: [B, N, F] 或 [B*T, N, F] # adj: [N, N]已经做过归一化 h self.linear(x) # [B, N, H] out torch.bmm(adj.unsqueeze(0).expand(h.size(0), -1, -1), h) return out这里最关键的是torch.bmm这一行邻接矩阵左乘特征矩阵等于把每个节点邻居的特征加权求和。线性变换放在乘法之前让模型先学会把原始特征映射到更抽象的表示空间再聚合邻居信息。如果邻接矩阵没有归一化聚合后节点特征的值会随邻居数量放大或缩小导致训练不稳定。所以我都会在预处理阶段对邻接矩阵做行归一化避免再在模型内部处理。有些项目会写成torch.matmul(adj, h)效果一样但要求 adj 永远是稠密矩阵。当节点数超过 1000 时我更推荐保留稀疏邻接矩阵并使用torch.sparse.mm。需要注意的是batched input 处理时稀疏矩阵和批量张量相乘不如稠密矩阵方便因此小规模实验用稠密矩阵反而更快。3.2 一个可运行的轻量基线GCN GRU在空间模块确定后时间模块我的第一选择是 GRU而不是 LSTM。GRU 参数量少在流量序列这种中等长度数据上收敛更快。下面是完整的轻量基线模型class GCNGRU(nn.Module): def __init__(self, num_nodes, in_features, hidden_dim, out_steps): super().__init__() self.gcn1 GCNLayer(in_features, hidden_dim) self.gcn2 GCNLayer(hidden_dim, hidden_dim) self.gru nn.GRU(hidden_dim, hidden_dim, batch_firstTrue) self.predictor nn.Linear(hidden_dim, out_steps) def forward(self, x, adj): # x: [B, T, N, F] B, T, N, F x.shape # 每个时间步独立做图卷积 x x.reshape(B * T, N, F) x F.relu(self.gcn1(x, adj)) x F.relu(self.gcn2(x, adj)) x x.reshape(B, T, N, -1) # 把节点维度与 batch 合并让 GRU 逐节点独立建模 x x.permute(0, 2, 1, 3) # [B, N, T, H] x x.reshape(B * N, T, -1) # [B*N, T, H] _, h_n self.gru(x) # [1, B*N, H] h h_n.squeeze(0).reshape(B, N, -1) # [B, N, H] out self.predictor(h) # [B, N, out_steps] return out.permute(0, 2, 1) # [B, out_steps, N]这个模型的关键设计是先把每个时间步的节点特征经过两层图卷积让空间信息充分融合再把融合后的序列交给 GRU 学习时间演变。GRU 输出最后一个隐藏状态再接一个全连接层直接预测多步。把 B 和 N 合并后送入 GRU会让所有节点共享同一套时间参数这在大规模路网中几乎不会导致欠拟合反而能提升泛化。如果要进一步提升精度可以把 GRU 换成 TCN 或者 Transformer。TCN 的优势是能并行训练但卷积核宽度和扩张率需要反复调Transformer 的注意力矩阵可以叠加在邻接矩阵上形成所谓时空注意力模块。但那些结构在数据量小于 10 万样本时很容易过拟合。我的经验是先跑通 GCN GRU确保数据流水线和指标计算正确再决定是否换复杂骨架。3.3 核心参数表与调整顺序参数名建议默认值调整方法滑动窗口 seq_len12观测到周期性越强窗口越长预测步长 horizon12先做 1 步预测再逐步扩展GCN 层数23 层以上要加残差否则训练不稳hidden_dim64节点数多可换 128但显存翻倍dropout0.1数据量小或过拟合明显时增大学习率0.001Adam 下 0.002 也可以但需要 warmupbatch_size64节点数大时减半直到显存不溢出训练时我会把学习率调度用 CosineAnnealing初始 0.001 跑 30 个 epoch验证 loss 不再下降时就把学习率减半。设置 hidden_dim 时优先看邻接矩阵中平均度数平均度数高意味着邻居信息多需要更大的 hidden_dim 才能容纳聚合后的信息。4. 训练与评估让时空图神经网络在数据划分上少踩坑很多模型在指标上“看起来很好”一上线就崩原因往往不是模型结构而是数据划分和评估指标出了问题。交通预测是时间序列任务任何随机打乱都等于把未来数据泄漏到训练集里。4.1 数据划分先按时间切再滑窗最后归一化我采用的方法是把原始数据按时间顺序切为 70% 训练、10% 验证、20% 测试并且保证验证集和测试集都晚于训练集。这样做的原因是交通流量存在明显的日周期和周周期如果训练集里混入测试集某天的早晚高峰数据模型学到的是“记忆”而非“预测”。train_flow flow[:int(len(flow) * 0.7)] val_flow flow[int(len(flow) * 0.7): int(len(flow) * 0.8)] test_flow flow[int(len(flow) * 0.8):] # 归一化统计量只能来自训练集 means train_flow.mean(axis0, keepdimsTrue) stds train_flow.std(axis0, keepdimsTrue) 1e-8 train_norm (train_flow - means) / stds val_norm (val_flow - means) / stds test_norm (test_flow - means) / stds注意means和stds的形状是[1, N]也就是说每个检测器按自己的均值方差归一化。这样能避免不同量级的路段相互影响。验证集和测试集的归一化也必须使用训练集的统计量不能各自计算否则模型在推理阶段会遇到训练分布外的输入。预测输出是整个归一化空间的数值回传到业务端前还要做逆归一化y_pred_real y_pred * stds means。4.2 归一化与逆归一化的顺序许多人也在这里犯错先切分数据再在滑窗后的样本上做归一化。这样统计量中会包含训练集和验证集的混合信息。正确的顺序是先切分再对每个切分后的整体计算统计量最后做滑窗。滑窗维度是 [样本数, 时间步, 节点数]mean的计算轴更复杂。如果直接在滑窗样本上算全局均值和方差时间维会被折叠数值差异不大但代码可读性更差。我更喜欢保留原始时间轴结构保持统计量的含义清晰。逆归一化必须在评估指标之前执行不能让 MAE 直接计算在归一化数值上。归一化后的速度一般在 0 附近波动RMSE 可能只有 0.3看起来非常小但还原成 km/h 后误差可能是 15 km/h。所以我在训练日志里同时打印归一化 loss 和真实量纲下的指标。4.3 评价指标MAE/RMSE/MAPE 零值掩码与计算边界交通数据经常含有零值可能是检测器故障、无车流或者缺失值填充。MAPE 遇上零值会直接除零因此评估指标需要加掩码。下面是我常用的实现def masked_metrics(y_true, y_pred, null_val0.0): # y_true/y_pred: [B, T, N] mask (y_true ! null_val).float() mae (torch.abs(y_true - y_pred) * mask).sum() / mask.sum() rmse torch.sqrt(((y_true - y_pred) ** 2 * mask).sum() / mask.sum()) mape ((torch.abs(y_true - y_pred) / (y_true 1e-8)) * mask).sum() / mask.sum() * 100 return mae.item(), rmse.item(), mape.item()这段代码中mask把零值位置全部剔除MAE 和 RMSE 都只在有效位置计算。MAPE 的分母加了1e-8防止除零但这个 epsilon 远小于真实流量值所以对数值影响很小。更严谨的做法是只对y_true 0的位置计算 MAPE也就是把 mask 改成y_true 0。如果数据里已经用 -1 标记缺失值就把null_val改成 -1。多步预测时我还会额外分 horizon 统计每个预测步的误差而不是只输出所有预测步的平均值。例如预测未来 12 步如果第 1 步 MAE 是 5第 12 步 MAE 变成 15说明模型其实只学到了平滑延续并没有学到长时变化。这时候需要检查训练数据是否有强周期成分以及时间模块是否太弱。5. 上线前必做的三件小事动态图更新、异常序列与推理加速模型在离线测试集上收敛后离真正上线还有一段路。交通路网本身在变化新道路开通、检测器迁移、施工限流都会让固定邻接矩阵失效。因此我通常不会直接把训练时的图结构固化而是留出更新接口。5.1 动态图更新滑动窗口重算邻接矩阵一个实用做法是每周用最近一个月的检测器坐标和流量重新计算邻接矩阵。坐标变化很少但动态相似度权重随时间变化所以要重新计算相关系数。用一个简单的版本号字段区分图版本模型加载时同时加载图结构和训练统计量graph_version time.strftime(%Y%m%d) A build_adj_by_distance(coords, sigma0.1, threshold0.5) np.savez(fadj_{graph_version}.npz, adjA)这样既保留历史图结构便于回滚也能让新增检测器尽快进入模型。动态图更新后必须重新跑一遍验证集因为邻接矩阵变化会直接影响输出分布。5.2 异常序列的前置过滤交通数据的异常更多来自检测器连续 10 个时间步流量为 0或者速度恒为 120 km/h这类序列不该直接送入模型。我的做法是在数据入口加一个规则当某个节点的当前值偏离历史同期均值超过 3 倍标准差时该节点置为缺失用最近邻节点均值补值。这种传统方法虽然不新但能避免单个故障检测器拉高整条路段的误差。5.3 推理加速把 batch 维度贴近真实请求上线初期最常见的问题是验证阶段 batch_size 设 64推理时只来一条请求耗时反而增加。GPU 处理小 batch 时无法打满算力。我的做法是批量推理把过去 1 小时的 12 个时间步数据按需求拼接成小 batch一次预测多个路径的流量。如果延迟要求小于 50ms则优先考虑 ONNX 导出导出前把邻接矩阵固化为常量张量避免运行时重建图。为了保证导出前后输出一致我会保存一个固定样本比较 PyTorch 输出和 ONNX Runtime 输出的最大绝对差是否小于 1e-4。实际工程里最有用的技巧之一是在测试集上对每个节点单独算一次 MAPE然后按误差从大到小排序。误差最大的前 5 个节点往往对应邻接矩阵中孤立点或数据质量最差的检测器。把这些节点单独画出来看时间曲线比再调一轮模型参数更能定位问题根因。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门