简介这是一份采用Python实现的交通流量预测项目源码核心思路是基于图卷积网络GCN建模道路拓扑与流量特征适合机器学习、图神经网络方向的课程设计或期末大作业参考。压缩包共16个文件约33.61MB涵盖数据预处理、多种图网络模型定义、训练评估与结果可视化的完整流程其中以Python脚本为主并包含PeMS04路网数据集、训练好的模型权重、说明文档和效果截图方便对照学习。项目实现了GCN、ChebNet与GAT等不同图网络模型的对比可视化脚本可展示不同时刻的预测流量曲线代码带有详细注释新手也能较快理解核心逻辑。目前已有245人学习下载应用价值得到初步验证。资源整体模块结构清晰可支撑完整的交通流量预测实验也适合在此基础上进行模型改进与参数调优。1. 图神经网络做交通流量预测为什么要从邻接矩阵说起拿到路网传感器数据的时候我第一反应也是把流量矩阵当成图像去处理。N 个传感器、T 个时间步排成一张 T×N 的表喂给 CNN 或者全连接网络。这类做法跑起来很容易但效果总是差一口气。原因不是模型容量不够而是路网根本不是一个规则网格每个传感器只和它物理上相邻的传感器有直接关联北京和上海的两个收费站不会因为列索引相邻就互相影响。GCN 图神经网络做交通流量预测核心就一句话把路网拓扑单独抽出来构造成邻接矩阵连同传感器时序信号一起送进图卷积网络。这个标题里的源码项目本质上就是在干这件事——用图的拓扑结构约束预测让每个传感器节点的输出同时接收邻居节点过去一小时的变化。适合谁看手头有传感器流量或速度数据、想跑时空预测但一直没把「图的构造」这一步想明白的工程师。后面几章我会把邻接矩阵怎么建、GCN 层怎么搭、训练时哪些细节会让指标直接崩掉逐个拆开讲。2. GCN 的核心算子拉普拉斯矩阵、消息传递与多项式近似2.1 为什么 CNN 处理不了不规则路网CNN 之所以在图像上有效是因为图像像素天然落在均匀网格上卷积核可以平移复用。路网没有这个性质。传感器的分布取决于道路走向和检测器安装位置两个相邻传感器之间的距离从几百米到几公里不等每个传感器的邻居数量也完全不同。拿固定大小的卷积核去扫这样的数据等于强行把不规则结构压进规则网格拓扑信息在压平的过程中就丢了。这也是为什么简单的 1D 卷积预测流量短期效果尚可一遇到早晚高峰这种空间相关性强的时段就开始失真——模型根本不知道两个相距 2 公里但同属一条快速路的传感器应该比物理上更近但隔着一堵墙的两个传感器更具关联性。图神经网络换了一种思路不再要求数据排成网格而是把每个传感器当作图上的一个节点传感器之间的道路连接关系作为边。信息在图上通过边传播每个节点的表征由它自己和邻居共同决定。对交通流量预测来说这套逻辑非常自然——下游路段的拥堵本来就会向上下游传播节点特征的空间聚合和真实道路上的流量传播方向是一致的。2.2 消息传递GCN 计算的核心循环图卷积对大多数工程师来说像一个黑匣子但把它的计算拆开看就是一个非常朴素的循环每个节点从邻居那里收集特征做一个加权求和然后通过线性变换和非线性激活输出新特征。在 PyTorch 里这个「消息传递」循环可以写成这样import torch import torch.nn as nn class GCNLayer(nn.Module): def __init__(self, in_dim: int, out_dim: int): super().__init__() self.gate nn.Linear(in_dim, out_dim) def forward(self, x: torch.Tensor, adj: torch.Tensor) - torch.Tensor: # x: [B, N, in_dim]B是批次N是传感器节点数 # adj: [N, N]归一化后的邻接矩阵 neighbor torch.matmul(adj, x) # 第一步聚合邻居特征 out self.gate(neighbor) # 第二步线性变换 return torch.relu(out) # 第三步非线性激活这段代码最核心的是torch.matmul(adj, x)。邻接矩阵的维度是 N×N它在节点维度上和特征做矩阵乘法输出里第 i 个节点的值就是邻接矩阵第 i 行所有非零位置对应节点特征的加权和。这一步就是「消息传递」的全部邻居特征被加权累加权重来自邻接矩阵。需要提醒一句这里直接用原始邻接矩阵做聚合数值上是不稳定的。如果一个节点有几千个邻居聚合后的特征范数会远大于邻居少的节点导致模型训练波动。所以在真实工程里我们会在聚合前先对邻接矩阵做归一化这就引出了拉普拉斯矩阵。2.3 对称归一化与多项式近似从公式到一阶简化业界最常用的归一化方式是「对称归一化」公式是A_norm D^{-1/2} (A I) D^{-1/2}其中 A 是原始邻接矩阵I 是自环矩阵让每个节点在聚合时包含自身特征D 是度矩阵——一个对角阵对角线上的元素是每个节点的邻居数加上自环后的总和。为什么要用对称归一化而不是单纯按度做行归一化因为行归一化只约束了每个节点的加权和为 1但会把低连接度节点的特征压得特别扁对称归一化同时考虑了行和列两个方向的度信息在高、低连接度节点之间保持均衡这也是 GCN 原始论文里的标准做法。import numpy as np def normalize_adj(adj): 对称归一化邻接矩阵D^{-1/2} (A I) D^{-1/2} adj adj np.eye(adj.shape[0]) # 加自环让节点聚合时保留自身特征 degree np.array(adj.sum(1)) # 每个节点的度 d_inv_sqrt np.power(degree, -0.5) d_inv_sqrt[np.isinf(d_inv_sqrt)] 0.0 d_mat np.diag(d_inv_sqrt) return d_mat.dot(adj).dot(d_mat)实现上就三步加自环、算度、两边各乘一次D^{-1/2}。真正理解这段代码要抓住一个点GCN 的整套推导是从谱域图滤波开始的原始形式需要对拉普拉斯矩阵做特征分解计算代价在几千个节点的路网上不可接受。后来用多项式近似去逼近滤波函数再把这个近似截断到一阶才退化成现在这个简单的聚合公式。换句话说你现在看到的D^{-1/2}(AI)D^{-1/2}是从一堆复杂的谱域推导里「简化到只剩骨架」的结果但它保留了最关键的性质每个节点只看自己和直接邻居且聚合权重受节点度影响。这个一阶近似的意义在于它把图卷积从理论研究拉进了工程可用的范围。你可以叠加多层 GCN 来扩大感受野——第一层看直接邻居第二层看两跳以内的邻居以此类推。但这里有个陷阱实际项目中 GCN 层数加到三层以上效果反而会下降。这个现象在交通流量预测场景里出现过很多次我在第 5 章会专门展开。3. 邻接矩阵构造的三种工程做法高斯核、K近邻与动态学习3.1 高斯核邻接矩阵距离阈值与 sigma 的选参逻辑拿到传感器坐标或距离矩阵之后第一步是构建邻接矩阵。最常见的做法是高斯核加权两个传感器之间距离越近边的权重越大距离超过某个阈值的节点之间不连边。这个做法背后有一个交通工程假设流量的空间相关性随距离指数衰减超过一定范围后相关性弱到可以忽略。import numpy as np def build_gaussian_adj(dist_matrix, threshold, sigma): 基于高斯核构造邻接矩阵 dist_matrix: [N, N]传感器两两之间的道路距离 threshold: 超过该距离的节点不连边 sigma: 高斯核的带宽控制距离衰减速度 adj np.zeros_like(dist_matrix) mask (dist_matrix threshold) (dist_matrix 0) adj[mask] np.exp(-np.power(dist_matrix[mask] / sigma, 2)) np.fill_diagonal(adj, 1.0) # 自环确保节点保留自身特征 return adj为什么threshold和sigma会直接影响预测精度路网上两个节点距离超过 5 公里它们的流量相关性已经非常弱保留这条边只会给模型注入噪声。如果阈值设得太小图会被切碎成若干个互不相连的小块某些传感器节点收不到任何邻居消息GCN 对它的预测完全退化成单节点的时间序列模型。sigma控制衰减速度我一般先取所有传感器对距离的中位数作为初始值再在验证集上做一次网格搜索范围通常是中位数的 0.5 倍到 2 倍。这一步值得花时间调因为sigma对短期预测的影响往往比模型层数还大。这里的玄学成分其实不多本质是找「空间相关性显著存在的距离范围」。你可以把构造出来的矩阵做一次稀疏度检查非零元素占比低于 5%说明阈值太紧高于 30%说明图中存在大量弱相关边。5%~15% 是一个经验合理的区间。3.2 K近邻补充与连通性检查避免图分裂距离阈值会带来一个真实风险在传感器分布稀疏的区域某些节点可能一条边都没有。高斯核只考虑绝对距离没有考虑每个节点的邻居数量导致结果不可控。常见做法是叠加一层 K 近邻约束——对每个节点保留与其距离最近的 K 条边再和距离阈值的结果取并集。from scipy.sparse import csr_matrix from scipy.sparse.csgraph import connected_components def add_knn_and_check_connectivity(adj, knn_k10): 在邻接矩阵上叠加K近邻并检查图是否连通 num_nodes adj.shape[0] # 对每一行取前k个最近的非零节点 for i in range(num_nodes): row adj[i].copy() row[i] 0 idx np.argsort(row)[::-1][:knn_k] for j in idx: if row[j] 0: adj[i, j] max(adj[i, j], row[j]) # 弱连通分量检查 graph csr_matrix(adj) n_components, labels connected_components(graph, directedFalse) if n_components 1: print(f警告图被分为 {n_components} 个连通分量) # 这里可以做合并处理比如连接各分量中距离最近的两个节点 return adj连通性检查不是可选项。如果图分裂成两个以上分量意味着某些传感器节点在整个训练过程中接收不到另一部分路网的信息这些节点的预测误差会明显高于连通区域内的节点。我在实际项目里见过这类问题某几条郊区路段的预测值在训练后期一直不下降排查半天最终发现是邻接矩阵阈值设太大这些路段成了孤岛。修复方式很直接对每个孤立节点强制给它连上物理距离最近的 K 个节点。这个操作对最终指标的影响比换模型结构还明显。3.3 静态邻接还是动态学习邻接一份选型对比邻接矩阵除了手动构造也可以作为模型参数端到端学习。动态邻接矩阵的想法是让模型自己决定节点之间的关联强度而不是依赖物理距离。对交通流量预测来说这个想法有诱惑力因为真实的交通关联会随时间变化——早高峰时连接市中心和郊区的路段关联强深夜时可能完全解耦。但实际工程里我一般建议先用静态邻接矩阵。原因有三第一静态邻接矩阵训练稳定可解释性强出了问题可以直接检查矩阵就能定位是拓扑问题还是模型问题第二动态学习的邻接矩阵容易退化成全连接矩阵每个节点都在看所有节点空间局部性被破坏计算量也随节点数二次上升第三传感器数据本身噪声大、样本量有限让邻接矩阵也参与学习会显著增加过拟合风险。什么时候上动态等你已经用静态矩阵跑出一个稳定基线并且确认瓶颈在空间关联的时变性上再考虑用注意力机制生成动态权重。第 6 章我会给一个具体做法。维度静态邻接矩阵动态学习邻接矩阵构造成本低只需距离数据高需要设计训练策略可解释性高可直接检查边权重低学习到的权重难以对应物理意义训练稳定性高收敛快低容易退化成全连接过拟合风险低高尤其在传感器数量大时适用场景大多数路网、短期预测基线拓扑动态变化的区域路网4. 用最小 GCN 复现流量预测窗口划分、模型定义与训练循环4.1 传感器数据的时间窗口划分输入 12 步预测 3 步交通流量预测的标准做法是滑窗预测。用过去一段时间的观测值预测未来一段时间。历史窗口长度取多少直接影响模型的感知范围窗口太短模型看不到流量的上升趋势早晚高峰的突变预测不出来窗口太长输入维度增大训练成本和过拟合风险同步上升。我在项目里的惯例是传感器数据如果按 5 分钟一个采样点历史窗口取 12 步也就是过去 1 小时预测未来 3 步未来 15 分钟。这个配置在预测时效和任务难度之间比较均衡。import numpy as np import torch def make_window(data, history12, horizon3): 滑窗构造训练样本 data: [num_samples, num_nodes, features] 返回 X [num_samples, history, num_nodes, features] Y [num_samples, horizon, num_nodes] samples data.shape[0] X, Y [], [] for i in range(samples - history - horizon 1): X.append(data[i:i history]) Y.append(data[i history:i history horizon, :, 0]) # 只预测流量字段 return torch.FloatTensor(np.array(X)), torch.FloatTensor(np.array(Y))注意这里的 Y 只取了第 0 个特征。传感器数据通常包含流量、速度、占有率三个字段预测任务可以只针对其中一个也可以三个一起预测。我建议第一版只预测流量字段把模型结构和工作流跑通之后再扩展多输出。另一个容易忽略的细节是数据划分方式。滑窗样本之间有大量重叠窗口 i 和窗口 i1 共享了 11 个时间步的输入数据。如果按照随机打乱的方式切分训练集和验证集就会造成严重的数据泄露——验证集样本的输入和训练集样本几乎一样验证指标会虚高。正确的做法是按照时间顺序切分前 70% 做训练、中间 10% 做验证、最后 20% 做测试并且用于归一化的均值方差只从训练段统计。4.2 教学版时空 GCN 的结构定义下面这个模型是给「跑通全流程」用的教学版结构。它只保留 GCN 加时序卷积的最小骨架不追求和论文结构完全对齐但能端到端训练也能在真实路网数据上得到一个可用的基线结果。完整的时空图卷积网络会用门控时间卷积、瓶颈层和残差连接结构熟了之后可以逐步往上加。import torch import torch.nn as nn class TrafficPredictor(nn.Module): 教学版时空图卷积GCN做空间聚合Conv1d做时间特征提取 def __init__(self, num_nodes, history12, horizon3, in_dim1, hidden_dim64): super().__init__() self.history history self.horizon horizon self.num_nodes num_nodes self.gc1 GCNLayer(in_dim, hidden_dim) self.gc2 GCNLayer(hidden_dim, hidden_dim) # 时间维卷积通道数为hidden_dim卷积长度是history self.time_conv nn.Conv1d(hidden_dim, hidden_dim, kernel_size3, padding1) self.regressor nn.Linear(hidden_dim * history, horizon) def forward(self, x, adj): # x: [B, history, num_nodes, in_dim] B, H, N, F x.shape # 空间聚合每个时间步独立过两层GCN spatial [] for t in range(H): h_t x[:, t] # [B, N, F] h_t self.gc1(h_t, adj) h_t self.gc2(h_t, adj) # [B, N, hidden_dim] spatial.append(h_t) # 时间维度重组和卷积 h torch.stack(spatial, dim1) # [B, H, N, hidden] h h.permute(0, 2, 3, 1) # [B, N, hidden, H] h self.time_conv(h) # [B, N, hidden, H] h h.permute(0, 2, 3, 1) # [B, N, H, hidden] h h.reshape(B, N, -1) # [B, N, H*hidden] pred self.regressor(h) # [B, N, horizon] return pred.permute(0, 2, 1) # [B, horizon, N]模型的处理逻辑分三步第一步对历史窗口内的每个时间切片独立做图卷积让每个时间点上节点特征已经聚合了邻居信息第二步在时间维度上做一维卷积捕捉流量在这个节点上的上升和下降趋势第三步把所有历史时间步的特征拼起来通过全连接层映射到未来 horizon 步。GCNLayer用的是第 2 章里那段消息传递代码。如果你手头的传感器数据包含了速度、占有率等多个特征把in_dim改成对应的特征维度就行。4.3 训练循环、早停与验证指标模型定义完之后训练循环本身没有太多花样但有两个习惯建议养成一是训练和验证数据都要归一化到同一个量纲并且统计量只来自训练集二是验证集必须按时间顺序连续切分不能随机抽样。def fit(model, train_loader, val_loader, adj, epochs150, lr1e-3, devicecpu, patience20): model.to(device) optimizer torch.optim.Adam(model.parameters(), lrlr, weight_decay1e-5) criterion torch.nn.MSELoss() adj adj.to(device) best_val float(inf) bad_epochs 0 for epoch in range(epochs): model.train() train_loss 0.0 for x_batch, y_batch in train_loader: x_batch, y_batch x_batch.to(device), y_batch.to(device) optimizer.zero_grad() out model(x_batch, adj) loss criterion(out, y_batch) loss.backward() optimizer.step() train_loss loss.item() model.eval() val_loss 0.0 with torch.no_grad(): for x_batch, y_batch in val_loader: x_batch, y_batch x_batch.to(device), y_batch.to(device) out model(x_batch, adj) val_loss criterion(out, y_batch).item() if val_loss best_val: best_val val_loss bad_epochs 0 torch.save(model.state_dict(), best_model.pt) else: bad_epochs 1 if bad_epochs patience: print(fearly stop at epoch {epoch 1}) break if (epoch 1) % 10 0: print(fepoch {epoch 1:03d} | train loss {train_loss:.4f} f| val loss {val_loss:.4f})早停的 patience 设 20 轮是一个保守取值。GCN 模型的收敛速度和 CNN 不太一样前 30 轮损失下降很快之后进入平台期如果 patience 设太小会把模型从平台期附近提前拉走。学习率 1e-3 配合 Adam 是这类任务最稳的起点如果发现验证损失出现震荡把学习率降到 3e-4 重跑即可。4.4 推荐起点参数表给一份可以直接照抄的起点配置。这些参数在大多数路网数据上都能稳定收敛后续再根据数据规模做微调。参数推荐值调整方向历史窗口12 步数据频率低或任务要求更长记忆时加大到 24预测步长3 步预测 30 分钟以上时加到 6但难度会上升GCN 层数2 层3 层以上出现过平滑谨慎尝试隐藏维度64传感器数少时降到 32多时加到 128批次大小64显存不足时减半学习率1e-3损失震荡时降到 3e-4训练轮数150配合早停patience 设 205. 交通流量预测踩坑排查归一化泄露、图分裂与过平滑5.1 归一化统计量泄露测试曲线看着漂亮换段数据就崩现象训练损失和验证损失都收敛得很漂亮预测曲线和真实曲线几乎重合但把同一套代码换到另一批传感器数据上指标直接翻车误差涨了一倍不止。原因预处理阶段用了全量数据的均值方差做归一化包括测试段。等价于模型在训练时已经偷看了测试集的整体分布测试指标没有任何参考意义。这是交通流量预测项目里最常见的无效结果来源很多人换了数据集之后怎么调参都救不回来根子不在模型在数据预处理。解决归一化统计量只从训练段计算验证段和测试段复用训练段的 mean 和 std。我现在的习惯是归一化脚本单独写输出一个配置文件里面只保存训练集的统计量。换数据或换任务时第一件事检查配置文件的统计量来源。5.2 邻接矩阵稀疏化后图分裂某些传感器预测值永远为零现象训练收敛后模型对大部分路段预测正常但少数传感器节点的预测输出始终接近一个常数误差明显高于其他节点。原因邻接矩阵构造时阈值过紧这些传感器节点没有任何有效边连入图中。GCN 的空间聚合对它们失效模型只能靠时间维卷积独自工作相当于这些节点被排除在了路网拓扑之外。解决检查归一化后邻接矩阵的连通分量数量。如果连通分量大于 1对孤立的节点强制补齐 K 近邻边连到物理距离最近的几个传感器上。补边的优先级是先距离阈值、后 K 近邻、最后手动指定。补完之后重新检查一次连通性直到整个图只剩一个连通分量。5.3 图卷积层数加到三层的过平滑陷阱现象层数从 2 加到 3、甚至加到 5 之后验证集指标非但没有提升反而持续劣化最终预测曲线趋于平缓尤其是高峰期流量被明显削峰。原因过平滑。多层 GCN 反复聚合邻居特征之后所有节点的表征逐渐趋同节点区分度下降。交通流量数据本身空间差异大高速路和城区支路流量差一个量级过拟合还没发生节点特征先被「抹平」了。解决把 GCN 层数限制在 2 层以内。感受野不够的问题用时间维度的卷积或者更宽的第一层来缓解而不是无脑加深。如果确实需要扩大感受野可以尝试残差连接——把输入直接加到输出上缓解信息在传输过程中的损失。5.4 模型退化成一阶自回归预测曲线是平移了一个时间步的输入现象验证集 MSE 很低但画出来的预测曲线和真实曲线总是错开一个时间步真实曲线的波峰刚出现模型给出的预测还在上一个时刻的值。原因模型学到了一个捷径——直接复制最近时刻的观测值。当训练数据存在强自相关性时这个策略的损失已经足够低模型不会费力去学空间拓扑关系。尤其在流量变化平缓的深夜时段复制最近值几乎是最优策略模型就赖上了这个方案。解决让评价指标和政策更严格。把 MSE 改成一阶差分损失预测值和真实值的差分误差迫使模型学习变化趋势而不是绝对值。同时观察高峰期时段的分段误差早晚高峰的误差比平峰高是正常的但如果高峰期误差是平峰的 3 倍以上就要考虑模型确实没有利用路网空间信息。5.5 按时间切分还是随机打散数据顺序决定模型上限现象训练损失很低、验证损失也很低但在后续「上线测试」阶段表现极差误差回到基线水平。原因原始数据按时间滑窗生成样本后如果直接用train_test_split(random_state)随机切分训练集和验证集会混入同一时间段的数据。由于窗口重叠训练样本的输入和验证样本的输入几乎一模一样验证集失去了意义。解决一律按时间顺序切分。前 70% 训练、中间 10% 验证、最后 20% 测试且切分点要在滑窗构造样本之前确定而不是在样本生成之后。更严谨一点的做法是按天切分训练集包含前若干整天验证集和测试集各自是连续的若干天避免一天内的早晚高峰数据被切碎后分到两个集合里。6. 从 GCN 走向时空图卷积评价指标与三个立刻能上手的进阶技巧6.1 三个立刻能上手的进阶点第一个进阶点是给空间聚合加残差连接。在两层 GCN 之后把输入特征直接加到输出上再进时间卷积。改动只有一行代码但对过平滑和梯度传播都有正面作用。我做完这个改动后验证集损失通常能掉 3%~8%并且高峰期削峰现象明显缓解。第二个进阶点是为工作日和节假日分别建立模型或加入时间特征。交通流量的周期性非常强工作日早晚高峰和周末的全天平缓是两种完全不同的空间相关模式。最简单的做法是在输入特征里拼一个时间独热编码告诉模型当前样本来自工作日还是周末更进一步的做法是为工作日和周末各训练一个模型预测时按日期选择。后者在两个模型都能稳定收敛的前提下效果上限更高。第三个进阶点是把静态邻接矩阵替换成注意力生成的动态权重。具体做法是不再直接使用归一化后的静态邻接矩阵做聚合而是用一个轻量网络根据当前时刻所有节点的特征动态生成每对节点之间的聚合权重。这一步在传感器网络比较大、路网拓扑确实随拥堵状态变化时有明显收益但它对训练稳定性要求高一定要在静态邻接矩阵基线稳定之后再做。6.2 验证指标的选择技巧和一次翻车经验验证 GCN 模型时不要只看整体 MSE。我会同时看 MAE 和按流量分桶的 MAPE把测试集样本按真实流量分成低、中、高三个量级分别统计误差。低流量时段的绝对误差本来很小MAPE 会被夜间接近零的流量值放大到离谱如果只看整体 MAPE你会误以为模型完全没训练好高流量时段的误差才是真正影响业务价值的指标。最后说一个我自己踩过的坑有一次做一个高速公路模拟路网预测用了全量数据归一化测试集曲线漂亮得不像话我一度以为是模型结构选对了。后来拿某天的真实记录做线下核对才发现那天的数据其实在训练阶段就已经参与了归一化统计。换数据重跑之后指标掉了两成。从那时候起我给自己定了个死规矩所有预处理脚本输出的统计量必须单独记录来源新数据进来第一件事核对统计量文件而不是先跑模型。希望帮到你。本文还有配套的精品资源点击获取