简介:一份面向智能交通与时空数据建模的学习资料,系统讲解城市大脑如何结合时空图神经网络,对交通流量、事故和天气数据进行实时监测与未来趋势预测,并探讨如何通过预测干预避免拥堵和事故,适合从事深度学习、机器学习、数据建模或智慧城市相关工作的读者参考。资源为PDF格式,包内共1个文件,压缩包约1.48MB,内容精炼、便于离线阅读,目前已有292人学习下载。文档以达摩院城市大脑实践为背景,按“数据接入—数据挖掘—预测干预—动态调整”的完整链路展开,覆盖交通流预测模型、时空图神经网络原理、低延时高并发技术及开源平台设计等关键点,为理解实时交通系统提供了清晰的技术脉络。读者可借此建立从大规模数据汇聚、分析挖掘到智能决策的全局认知,快速把握交通预测领域的前沿应用思路与工程落地要点。
1. 为什么交通预测要走向时空图神经网络
城市道路上的拥堵不是凭空出现的。一个路口在早高峰突然排起长队,往往不是这个路口本身出问题,而是上游几个路口的流量和速度在十分钟前就变了。达摩院城市大脑团队在演讲中把这种关系比喻成水波:一个波纹推动另一个波纹。理解这条传播链,传统时间序列模型很难做到,因为路口之间的依赖是图结构、非欧几里得的。时空图神经网络(STGNN)把路网建模成图,把交通流量当作图上随时间变化的信号,用图卷积与序列模型一起刻画“空间传播+时间演化”。这份PDF把这个思路放进城市大脑完整链路,从数据接入到数据挖掘再到预测干预,给做交通预测或时空建模的工程师提供了一个可拆解的样本。一个反直觉的结论是:要预测某个路口,真正值得分析的不是该路口的历史,而是它周围路网的联合状态。
2. 从路口到图节点:交通预测的数据建模与邻接矩阵构造
2.1 为什么路网必须用图而不是网格
先把PDF里的城市大脑场景拆成建模问题。城市大脑实时监测每个路口的车辆流量和速度,这些数据天然就落在路口节点上,节点之间由道路连接。如果像处理图片那样把路网切成网格,会切断路口的连通顺序;如果只按距离把路口聚在一起,又会忽略道路方向。图结构不一样,节点就是路口,边就是道路或相关性,邻接矩阵决定了一个节点的状态如何被邻居影响。这个选择不是建模偏好,而是交通传播规律的直接映射:流量和速度从上游节点往下游节点传递,图卷积正好能把这个传播过程参数化。
2.2 节点特征、边特征和时间片组织
构建STGNN输入时,每个时间片对应一个图,图上每个节点有一组特征。结合PDF中提到的流量、事故、天气数据,我一般会这样组织:
| 特征类型 | 具体字段 | 说明 |
|---|---|---|
| 节点流量特征 | 车流量、平均速度、占有率 | 直接来自感应线圈、微波或摄像头计算 |
| 节点事件特征 | 是否事故、事件等级、是否管控 | 由计算机视觉识别和交警上报融合得到 |
| 外部特征 | 天气、温度、是否节假日、是否大型活动 | 用于修正预测分布,尤其是活动带来的波动 |
| 边的空间特征 | 道路长度、车道数、方向、通行能力 | 可选,用来给图的边加权 |
节点特征要做归一化和时间对齐。比如流量和速度量纲不同,速度要是0到120km/h,车流量可能是0到2000辆/小时,直接拼接会让梯度被大数值特征主导。常见的做法是分别做Z-score归一化,保存均值和方差,在推理时用同一套统计量。时间对齐则要控制在同一个时间片内,比如每5分钟一条记录,所有路口数据都落到整5分钟的时间戳上,缺失的片段先做线性插值,再做滑窗切片。
2.3 邻接矩阵的三种常见构造方式
PDF中强调“路口之间受到周围交通流影响”,这个影响如何变成矩阵有讲究。最简单的是把路网拓扑转成0-1邻接矩阵:两个路口有路段直接相连就为1,否则为0。但这种方式对距离不敏感,而且路网不完整时矩阵会很稀疏。另一种是用高斯核根据路口距离构造加权矩阵:A_ij = exp(-d_ij^2 / sigma^2),这样近距离节点在聚合时权重更高。最后一种是基于数据计算相关矩阵:用历史流量序列算Pearson相关系数,把相关系数大于阈值的路口连接起来,适合拓扑未知但流量模式相似的场景。
实际项目中我会把拓扑矩阵和数据相关矩阵相加再归一化,避免单一来源失真。下面是构造高斯核邻接矩阵的代码:
import numpy as np from scipy.spatial.distance import cdist def gaussian_adjacency(coords, sigma=0.1, threshold=0.01): # coords: N x 2,每个路口的经纬度或投影坐标 dist = cdist(coords, coords, metric='euclidean') adj = np.exp(-dist**2 / (2 * sigma**2)) # 去掉自连接,并剪掉过小的权重,保持邻接矩阵稀疏 np.fill_diagonal(adj, 0) adj[adj < threshold] = 0 return adj这里的sigma是空间衰减系数,越大表示影响范围越广。我一般会把sigma设置为路口平均间距的0.5到1倍,再观察验证集误差。threshold设为0.01是为了在训练时避免让每个节点都成为全图的全局邻居,否则图卷积退化成全局平均池化。构造完之后最好做一次行归一化,让每个节点的邻居权重和为1,这样数值更稳定。如果路网节点超过几千个,建议把邻接矩阵转成scipy.sparse.csr_matrix,后面图卷积可以直接用稀疏矩阵参与乘法,内存占用从O(N^2)降到O(E)。
还有一点容易忽略:邻接矩阵的稀疏化会影响训练速度。N个路口全连结矩阵是N平方的存储,500个路口就是25万个浮点数,还算能接受;如果扩展到5000个路口,就是2500万个数,必须转稀疏。我通常用KNN把每个节点只保留k个最近邻居,k一般取10到20,这样既能保留空间传播结构,又能让GCN在mini-batch下跑得动。并行处理时也方便,直接把稀疏邻接矩阵转成torch.sparse_tensor,在GPU上做稀疏乘法,速度能快一个数量级。
3. 时空图卷积网络落地:用图卷积和GRU搭建交通预测基线
3.1 图卷积是怎么把邻居信息聚合起来的
交通预测的输入格式是(X, A):X的形状是[N, T, F],N是路口数,T是历史时间步长,F是特征数;A是N×N邻接矩阵。图卷积要做的是在空间维度上聚合邻居特征。最常见的定义是Kipf提出的GCN:H' = D^-0.5 * A_hat * D^-0.5 * H * W,其中A_hat是加了自环的邻接矩阵,D是度矩阵。这一层做的事情很简单:每个节点把邻居的特征加权求和,再过一个线性层。这对路网来说很合理,因为一个路口的流量变化本来就受邻居影响。
不过PDF里提到的影响是“流量”和“速度”两个方向,这更适合用扩散卷积:不只考虑一跳邻居,还要考虑多跳传播。扩散卷积把状态转移分解成前向和后向随机游走,参数上分开权重,公式上相当于在A的两侧都做聚合。在PyTorch里实现时,我并不直接写稀疏矩阵乘法,而是先把邻接矩阵转成归一化的转移矩阵,再通过torch.sparse.mm做聚合,内存和速度都合适。实际使用中我只做两跳扩散,超过两跳的信息增益很小,但参数量翻倍。
3.2 时空模块组合:GCN层放在GRU之前还是之后
空间和时间两个维度都要建模,常见的做法有两种。一种是STGCN那种“时间门控卷积+GCN”交替堆叠;另一种是把图卷积嵌进GRU的更新门和重置门里,也就是GraphGRU。我推荐先把问题做简单:用GCN对每个时间步做空间聚合,再输入GRU做时间建模。这个结构参数少、训练快,作为基线足够用来验证数据质量。
下面是一个可直接跑的模型定义,输入是连续15个时间步的流量,预测未来6个时间步的路口流量:
import torch import torch.nn as nn class STGCNBaseline(nn.Module): def __init__(self, in_dim, hidden_dim, horizon): super().__init__() self.gcn = nn.Linear(in_dim, hidden_dim) # 基线上先用全连接模拟GCN self.gru = nn.GRU(input_size=hidden_dim, hidden_size=hidden_dim, num_layers=2, batch_first=True) self.regressor = nn.Linear(hidden_dim, horizon) def forward(self, x, adj): # x: [B, T, N, F],adj: [N, N] 已经是归一化邻接矩阵 B, T, N, F = x.shape h = None outputs = [] for t in range(T): xt = x[:, t, :, :] # [B, N, F] xt = torch.einsum('bij,bjk->bik', adj, xt) # 空间聚合 xt = self.gcn(xt) # [B, N, hidden] xt, h = self.gru(xt, h) outputs.append(xt) out = torch.stack(outputs, dim=1) # 用最后一个时刻的隐状态映射到未来horizon步 return self.regressor(out[:, -1, :, :])上面这个版本把adj直接作为因子乘进去,省去复杂的GCN实现,但在真实项目中GCN需要做对称归一化和权重训练。我一般会在基线版本里使用PyTorch Geometric的GCNConv,参数更准确。这里的要点是时间循环那块,GRU每次接收的输入先做了空间聚合,等于让“空间注意力”先发生,再让GRU学习时间依赖。隐藏维度hidden_dim在交通数据上取64到128比较合适,太小学不到空间传播,太大会在数据量小时过拟合。
3.3 训练预设:时间窗口、隐藏维度和早期停止
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 历史时间步T | 12或15 | 覆盖早高峰前15分钟,太短抓不住趋势 |
| 预测步长horizon | 6到12 | 对应未来6到12个5分钟间隔,太长误差爆炸 |
| GCN隐藏维度 | 64 | 与GRU隐藏维度保持一致,方便残差连接 |
| GRU层数 | 2 | 1层欠拟合,3层在小数据集上退化 |
| 学习率 | 1e-3起步 | Adam优化器,每10轮衰减0.5 |
| batch size | 32 | 路网小于500个节点时够用 |
我一般会把输入数据按5分钟一个时间片切分,预测未来30分钟内的流量。训练时先做早停:验证集MAPE连续10轮不下降就回滚到最优权重。这里特别要注意的是,预测时不能把未来值泄漏到输入里。GRU的循环输入只来自历史窗口,滚动预测时要把预测值作为下一个时刻的输入,而不是把真实值喂回去,否则在测试集上的误差会看着很小,实际部署时完全失灵。
对于更复杂的场景,也可以在GRU之后再加一个空间注意力层,但要以基线模型的误差作为参照。我见过不少项目一上来就上Transformer和注意力,结果在小数据集上误差反而比GRU基线高。交通流预测的时序依赖相对平稳,用循环结构配合图卷积已经能抓住大部分模式,真正提升效果的是特征质量和邻接矩阵构造,而不是一味加大模型。
4. 从训练到上线:城市大脑交通预测的评估指标和低延迟部署
4.1 数据接入和实时特征对齐
城市大脑场景里,数据不是存在一个整齐的离线文件里,而是从微波、线圈、摄像头以及天气服务实时汇聚的。对于训练集,我们需要把不同数据源按时间对齐到同一分钟粒度,比如把每5分钟的流量做均值降采样,同时把摄像头识别出来的事件数据按路口和时间匹配到节点特征上。这块常见的问题是路口ID不统一。A系统把路口叫“32001”,B系统叫“西湖大道-延安路”,直接match会丢失一半数据。我一般会在建模前先做一份标准路口表,把所有来源的ID映射到一个统一的node_id,再构造邻接矩阵。
对实时推理来说,低延时不是把模型压得越薄越好,而是要让数据在被预测的时刻已经完整到达。比如要预测9:00的流量,8:55到9:00的流量数据必须在这5分钟内完成清洗和特征拼接。如果某个线圈采集器故障,就会出现missing值,这时不能用历史均值硬填,而是要把缺失节点在邻接矩阵里的聚合权重重新归一化,并用它的邻居流量近似补全。下面这段代码处理这种图上的缺失值:
def fill_missing_with_neighbors(adj, flow, missing_nodes): # flow: [N, F],邻接矩阵已经行归一化 filled = flow.copy() adj = adj.copy() for node in missing_nodes: neighbors = np.nonzero(adj[node])[0] if len(neighbors) == 0: filled[node] = 0.0 else: weights = adj[node][neighbors] weights = weights / weights.sum() filled[node] = (flow[neighbors] * weights[:, None]).sum(axis=0) return filled这个方式相比全局均值的好处是保持了空间连续性:某个路口的流量异常时,它的邻居通常也有同方向的变化。注意adj传入前要先做归一化,否则邻居权重和不为1,补出来的数值会偏大或偏小。对于事件类特征,如果缺失,我会用上一个时间片的值填充,因为交通事故状态不会频繁跳变。
4.2 损失函数和评估指标的选择
交通预测通常用MAE、RMSE、MAPE三件套。RMSE对峰值流量更敏感,适合衡量拥堵场景下的误差;MAPE对零流量很敏感,夜间数据会让它变得很大,所以很多论文里会对MAPE加一个最小值限制或者只统计流量大于某个阈值的样本。我复现这份PDF里的场景时会按工作日早高峰7:00到9:00单独切片评估,因为城市大脑最关心的是拥堵时段的表现。
损失函数我用Huber损失替代MSE。原因是流量数据会偶发极端值,比如交通事故后的流量突然归零,MSE会对这些异常样本给过大梯度,Huber在误差大于delta时退化为L1,对异常有更好的抗性。delta通常设为RMSE的0.1倍,在训练初期用RMSE估计一个初值也可以。
4.3 训练循环、梯度裁剪与断点续训
下面给出一个完整的训练循环骨架,里面穿进了评估、早停和checkpoint保存:
def train_model(model, train_loader, val_loader, adj, epochs=100): optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.5) best_loss = float('inf') bad_epochs = 0 for epoch in range(epochs): model.train() for batch in train_loader: x, y = batch optimizer.zero_grad() pred = model(x, adj) loss = huber_loss(pred, y, delta=0.1) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() scheduler.step() val_loss = evaluate(model, val_loader, adj) if val_loss < best_loss: best_loss = val_loss bad_epochs = 0 torch.save(model.state_dict(), 'best_model.pt') else: bad_epochs += 1 if bad_epochs >= 10: print(f'early stop at epoch {epoch}') break这里clip_grad_norm很重要。STGNN里的GCN对邻接矩阵做矩阵乘法,梯度在深层时容易爆炸,特别是用GRU展开15步之后,梯度范数经常超过10。clip到5.0能稳定训练。早停看验证集而不是训练集,避免把模型保存到过拟合的点上。断点续训也很实用:城市大脑的实时数据集会持续增加,我会每一天把新的观测追加到训练集,然后用上一次的checkpoint继续训练,而不是从随机初始化开始,否则前面学到的空间相关性会被冲掉。
为了理清上线时的资源分配,我常用下面这张表做容量评估:
| 部署项 | 配置建议 | 说明 |
|---|---|---|
| 模型推理框架 | TorchScript或ONNX Runtime | 去掉Python的前后处理开销 |
| 节点数规模 | 500以下用稠密矩阵 | 500以上建议稀疏邻接矩阵 |
| 推理频率 | 每5分钟一轮 | 与数据产生节奏一致即可,不必每秒钟跑 |
| 并发通路 | 单实例配4线程 | 模型小,瓶颈在特征拼接和邻接矩阵读取 |
这张表说明5分钟推理周期下,CPU和内存压力都不大,真正的瓶颈几乎都在数据处理管道上。
5. 从预测到干预:动态闭环验证和模型封装的两个细节
5.1 预测干预的动态模型怎么验证
这个PDF强调了“预测-干预-反馈”动态环路。交通预测模型在真实环境中不能只静止地预测,因为信号灯调整后会改变车流的时空分布,历史分布和未来分布会偏移。所以模型上线时,我一般会先做影子模式:不做干预,只让模型跟真实观测对比。等误差稳定后,再把模型的预测结果交给信号机优化模块,并记录干预前后的流量变化。干预反馈的数据不能直接进训练集,要先做漂移检测,比如用PSI检验干预前后的流量分布差异,差异超过阈值时就要触发模型重训练。这个闭环看起来简单,但它解释了为什么PDF的链路要把预测和干预放在一起设计。
5.2 把模型导出成隐藏路网结构的推理包
PDF里提到第三方算法接入时要保护模型细节。当前开源平台普遍支持ONNX或TorchScript导出。在导出时要把邻接矩阵做成模型常量,而不是每次推理时重新传进去,这样一方面更快,另一方面也把路网结构隐藏起来。ONNX导出的关键是把动态维度固定住:一个batch预测数据输入,序列长度、节点数和特征数都不变,这样算子融合更彻底。
下面这段是TorchScript导出时的一个注意点:
model.eval() traced = torch.jit.trace(model, (example_input, adj)) traced.save('traffic_stgnn.pt')如果模型里有依赖batch维度的循环,比如用GRU递归展开,trace时要把输入batch固定为1。导出后在实际推理里,我一般把推理进程做成一个常驻服务,通过共享内存读实时流量数据,再以固定间隔触发推理。低延时的关键不是把模型压缩得多小,而是把模型输出结果直接写到信号机系统的输入队列里,省掉中间文件的落地。最后还有一个容易踩的坑:TorchScript的trace是执行导向的,如果代码里有if分支依赖张量数值,trace会漏掉分支,需要改用torch.jit.script,或者把分支移到图卷积的前后处理里。
本文还有配套的精品资源,点击获取