ARTICLE DETAIL

资讯详情

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

图卷积网络+LSTM:Python实现城市交通流预测实战全解析

图卷积网络+LSTM:Python实现城市交通流预测实战全解析 简介这是一套面向交通流预测与时空图神经网络研究者的完整项目代码包围绕“城市交通流预测”场景集成了T-GCN、A3T-GCN、AST-GCN、KST-GCN四种GCN变体并附有HA、ARIMA、SVR、GCN、GRU五种基线模型可帮助读者系统理解时空相关性建模、注意力机制与知识驱动方法便于横向对比与论文复现。压缩包共164个文件以Python源码42个py、交通流数据集42个csv、训练检查点与指标记录meta/data/checkpoint、batch_loss、batch_rmse等及说明文档9个md为主涵盖模型定义、训练脚本、数据预处理与可视化等整体约43.12MB目录按模型分别组织便于按需检索。目前已有71人学习下载代码均经过运行测试适合计算机相关专业的在校学生、科研人员用于毕业设计、课程设计或算法进阶。下载后可通过README.md快速上手若遇到环境或运行问题也支持私聊远程教学获得针对性指导。1. 图卷积网络预测城市交通流这个 python 项目到底解决什么问题拿到一批城市路网传感器数据想预测 15 分钟后每条路的车辆速度大多数新手会直接拿 LSTM 硬训结果预测曲线像一条被拉直的橡皮筋——全天都贴着平峰均值走。这个 python 项目之所以值得做是因为它把路网当成一张图而不是一堆孤立检测点用图卷积网络GCN让相邻路段“互相通气”再配合 LSTM 之类的时序模块捕捉早晚高峰的演化节奏。源码和文档说明覆盖了从数据处理、邻接矩阵构建到训练评估的完整链路适合做毕设选题、时空数据挖掘入门以及智慧交通方向的技术预研。这篇笔记按“原理、复现、调参、避坑”讲清楚怎么把它跑通以及哪些参数最值得花时间调。2. 用图卷积还是普通卷积路网结构建模与图卷积层的核心代码2.1 把路网流量数据切分成图邻接矩阵和特征矩阵的构造交通流预测的输入数据最常见的形态是一张二维表每一行是一个时间片通常 5 分钟每一列是一个路测传感器单元格里是该传感器在这个时刻上报的速度、流量或占有率。拿到仓库里的原始数据后第一步不是建模而是先把长表转成这种标准形态。import pandas as pd import numpy as np # 原始探测数据通常是长表: timestamp, sensor_id, speed df pd.read_csv(raw_sensor_data.csv) pivot df.pivot_table(indextimestamp, columnssensor_id, valuesspeed) pivot pivot.sort_index() # 传感器偶尔会漏报, 先做前向填充, 再对开头缺失做后向填充 pivot pivot.ffill().bfill() data pivot.values.astype(np.float32) print(数据形状:, data.shape) # [时间片数, 传感器数]这段代码里pivot_table负责把长表转成宽表ffill().bfill()处理漏报。工业落地时往往还会加一步删除连续缺失超过 10 分钟的传感器列因为纯靠插值补出来的序列会对模型产生误导。形状对齐是复现源码的第一步很多报错都出在这因为仓库文档里写的num_nodes和你实际转出来的列数对不上。有了节点特征还需要定义“哪些节点相邻”。路网上的传感器不是均匀网格不能用普通卷积常见的做法是用传感器之间的实际路网距离构造邻接矩阵A让距离近的节点互相影响。def build_adjacency(dist_matrix, sigma20.1, threshold0.5): # dist_matrix: [num_nodes, num_nodes], 单位是 km, 由路网拓扑算出来 num_nodes dist_matrix.shape[0] adj np.zeros((num_nodes, num_nodes)) for i in range(num_nodes): for j in range(num_nodes): if i j: adj[i][j] 0.0 elif dist_matrix[i][j] threshold: adj[i][j] np.exp(-dist_matrix[i][j] ** 2 / sigma2) return adjsigma2控制高斯核的衰减速度threshold决定多远算邻居。这两个参数在 4.2 节单独展开因为它们在源码里属于“调了会明显影响结果”的项目。这里先记住一个结论邻接矩阵的物理含义是“节点之间的空间相关性”不是随便生成一个对称矩阵就行。2.2 图卷积层的两种实现思路和一段可直接运行的 GCN 代码图卷积在论文里分频谱域和空间域两派。频谱域从图拉普拉斯矩阵的特征分解出发用 Chebyshev 多项式做近似工程实现里更常见的是它的简化版——一阶 GCN公式长这样H σ(D̂⁻¹ᐟ² Â D̂⁻¹ᐟ² H W)其中 Â 是加自环后的邻接矩阵D̂ 是它的度矩阵。翻译成人话就是先把自己和邻居的特征加权求和再做一次线性变换最后过激活函数。所谓“卷积”在图上其实就是一个聚合操作。import torch import torch.nn as nn class GCNLayer(nn.Module): def __init__(self, in_dim, out_dim): super().__init__() self.weight nn.Parameter(torch.randn(in_dim, out_dim)) self.bias nn.Parameter(torch.zeros(out_dim)) def forward(self, x, adj): # x: [batch, num_nodes, in_dim] support torch.matmul(adj, x) # 聚合邻居特征 out torch.matmul(support, self.weight) self.bias return torch.relu(out)这段代码的关键在torch.matmul(adj, x)它把每个节点的特征替换成邻居特征的加权平均权重就是邻接矩阵里的值。weight是跨所有节点共享的这也是图卷积能处理“节点数量变化”的原因——换一张路网只要邻接矩阵维度对模型权重不用改。这里有个容易忽略的预处理聚合之前一定要做对称归一化否则度数高的节点特征会被放大训练不稳定。def normalize_adj(adj): # 加自环, 让节点自身信息也参与聚合 adj adj np.eye(adj.shape[0]) rowsum adj.sum(axis1) d_inv_sqrt np.power(rowsum, -0.5) d_inv_sqrt[np.isinf(d_inv_sqrt)] 0.0 d_mat_inv_sqrt np.diag(d_inv_sqrt) return d_mat_inv_sqrt adj d_mat_inv_sqrt归一化后的邻接矩阵每一行加起来是 1特征聚合后数值范围基本可控。源码里的compute_adjacency.py通常就是做这件事只是可能换成了稀疏矩阵写法以省内存。结构上不用被公式吓住落地时就是“归一化 聚合 线性变换”三步。2.3 空间图卷积不够时间维度才是交通流预测的难点图卷积解决了“空间依赖”问题但它的输出仍然是逐时间步的。交通流是一个时间序列当前时刻的速度和过去一小时的速度强相关早晚高峰有明显的周期性。所以项目源码里的完整模型一般是“GCN 时间序列模块”的组合一种是用 LSTM/GRU 沿时间维建模另一种是用一维卷积或门控时间卷积。我一般建议先跑通第一种因为 LSTM 的输入输出形状更容易理解。整个模型的形状流转是原始数据[batch, seq_len, num_nodes]先做图卷积聚合成[batch, seq_len, num_nodes, hidden]再压成时间序列格式喂给 LSTM最后通过全连接输出预测值。这个组合也是该领域公开论文基线的常见结构像 DCRNN 用扩散卷积加 GRUSTGCN 用图卷积加门控时间卷积Graph WaveNet 用自适应邻接矩阵加扩张卷积。它们之间最大的区别就在于“时间维怎么建模”和“邻接矩阵是否可学习”。新手复现源码时不需要一上来看懂所有变体先抓住“空间靠图卷积时间靠 LSTM/卷积”这个骨架就够了。3. 从项目源码跑通到最小训练流程文档说明怎么看、数据怎么喂3.1 拿到源码和文档说明后按这个顺序读项目源码仓库里通常有一份文档说明内容一般包括环境依赖、数据集下载方式、训练入口、复现指标和文件结构说明。对第一次接触图卷积交通流预测的人我建议按这个顺序读先看 README 里的数据格式说明再看模型定义文件最后看训练脚本。不要一上来就pip install -r requirements.txt然后直接跑因为数据集路径、传感器数量和模型里的num_nodes很可能不匹配。文档说明里最容易被忽略的是环境版本。很多源码是在旧版本 PyTorch 上写的如果你用新版跑可能遇到torch.load的weights_only参数变化或者np.matrix被移除之类的兼容问题。遇到这类问题先看报错发生在哪个文件再结合文档里的环境要求定位不要轻易重装环境多数情况是某一行 API 需要适配。代码结构上这类项目一般会有几个固定模块数据预处理脚本、邻接矩阵构建脚本、模型定义文件、训练主脚本、评估脚本外加一个放实验配置的目录。搞清楚它们之间的调用关系比逐行读源码高效得多。3.2 数据预处理与滑窗切分复现前先把数据形状对齐训练样本的构造方式决定了模型看到什么。交通流预测的标准做法是滑窗用过去 12 个时间步1 小时预测未来若干个时间步。def sliding_window(data, seq_len12, pred_len3): # data: [num_samples, num_nodes], 时间片在前 num_samples, num_nodes data.shape xs, ys [], [] for i in range(num_samples - seq_len - pred_len 1): x data[i:i seq_len] # 过去 1 小时 y data[i seq_len:i seq_len pred_len] # 未来 15 分钟 xs.append(x) ys.append(y) return np.array(xs), np.array(ys) train_x, train_y sliding_window(train_data, seq_len12, pred_len3) print(train_x:, train_x.shape) # [样本数, 12, num_nodes] print(train_y:, train_y.shape) # [样本数, 3, num_nodes]seq_len取 12 是因为交通数据通常是 5 分钟粒度12 步正好是 1 小时超过 1 小时的历史信息对短期预测帮助递减。pred_len取 3 是预测未来 15 分钟这是交通预测里最常用的短期场景。注意滑窗切分时不要打乱时间顺序训练集、验证集、测试集按时间先后切分而不是随机切分否则会引入数据泄露。3.3 最小可运行的训练循环与参数配置表为了让训练流程先跑通我一般会用一个简化版模型替代源码里的完整模型确认数据处理没问才换回原版。简化版同样包含图卷积聚合和 LSTM 时间建模只是省了注意力机制和可学习邻接矩阵。import torch import torch.nn as nn from torch.utils.data import DataLoader, TensorDataset class SimpleSTGCN(nn.Module): def __init__(self, num_nodes, hidden_dim64): super().__init__() # 图卷积聚合后, 每个时间步的特征还是 num_nodes # LSTM 沿时间维建模, 输入是每个节点聚合后的速度 self.lstm nn.LSTM(input_sizenum_nodes, hidden_sizehidden_dim, batch_firstTrue) self.fc nn.Linear(hidden_dim, num_nodes) def forward(self, x, adj): # x: [batch, seq_len, num_nodes], adj: [num_nodes, num_nodes] x_gcn torch.matmul(adj, x.permute(0, 2, 1)) # 聚合邻居 x_gcn x_gcn.permute(0, 2, 1) # 还原为时序格式 h, _ self.lstm(x_gcn) # [batch, seq_len, hidden] out self.fc(h[:, -1, :]) # 取最后时间步 return out.unsqueeze(-1) # [batch, num_nodes, 1] model SimpleSTGCN(num_nodestrain_x.shape[2], hidden_dim64) optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-5) criterion nn.MSELoss() def train(model, train_x, train_y, val_x, val_y, adj, epochs50): dataset TensorDataset(torch.tensor(train_x), torch.tensor(train_y)) loader DataLoader(dataset, batch_size32, shuffleTrue) best_mae float(inf) for epoch in range(epochs): model.train() total_loss 0 for x, y in loader: optimizer.zero_grad() out model(x, adj) # 输出 [batch, num_nodes, 1] loss criterion(out, y.unsqueeze(-1)) loss.backward() optimizer.step() total_loss loss.item() val_pred model(torch.tensor(val_x), adj).squeeze(-1).detach().numpy() val_mae np.mean(np.abs(val_pred - val_y)) if val_mae best_mae: best_mae val_mae torch.save(model.state_dict(), best_model.pth) if epoch % 10 0: print(fepoch {epoch}: loss {total_loss / len(loader):.4f}, fval_mae {val_mae:.4f})这段代码里的model(x, adj)中x的维度是[batch, seq_len, num_nodes]adj是预计算好的归一化邻接矩阵在训练开始前用 numpy 算好转成 torch tensor。注意y.unsqueeze(-1)这一步源码里常常因为输出和标签形状不一致报错所以要在数据处理时就定死约定。参数配置上我一般按这个表起步参数建议值说明学习率1e-3Adam 默认loss 震荡就降到 3e-4batch_size32 或 64取决于显存序列模型显存占用偏高epoch50配合早停patience 设 10seq_len12过去 1 小时pred_len3未来 15 分钟hidden_dim64LSTM 隐层维度数据量大可以加到 128weight_decay1e-5防止过拟合这个简化版模型跑通后换回源码里的完整模型只是替换model的部分数据加载、训练循环、评估逻辑完全可以复用。如果简化版都跑不出合理结果大概率是数据形状或邻接矩阵构建出了问题而不是模型的问题。4. 必调参数预测步长、邻接矩阵构建方式和归一化策略4.1 预测步长选 1 步还是 12 步多步预测的两种写法预测步长是第一个要敲定的参数。短期预测未来 5~15 分钟相对稳定误差主要来自随机波动长期预测1 小时以上需要模型真正理解拥堵的演化难度明显上升。经验上5 分钟粒度的数据里预测未来 1 步的 MAE 最低3 步15 分钟还在可用范围12 步1 小时开始出现明显的平滑效应——预测曲线逐渐失去细节。多步预测有两种实现方式。一种是迭代预测模型只输出下一步然后把预测值拼回输入序列滚动预测接下来的每一步。def iterative_predict(model, x, adj, pred_steps12): # x: [1, seq_len, num_nodes], 初始输入 model.eval() cur x.clone() preds [] with torch.no_grad(): for _ in range(pred_steps): y_next model(cur, adj) # [1, num_nodes, 1] preds.append(y_next.squeeze(0)) y_expand y_next.squeeze(-1).unsqueeze(0) # [1, 1, num_nodes] cur torch.cat([cur[:, 1:], y_expand], dim1) return torch.cat(preds, dim0).numpy() # [pred_steps, num_nodes]迭代预测的优点是模型结构简单训练时只需要拟合下一步缺点是误差会累积预测步数越多偏差越大。另一种是直接多输出修改输出层一次预测未来多个时间步。这种方式训练时标签就是[batch, pred_len, num_nodes]直接优化多步误差但输出层参数量变大训练难度也高。源码里两种实现都可能出现判断方法是看训练时y的维度。如果y是三维[batch, pred_len, num_nodes]就是直接多输出如果y是四维且每次只算下一步 loss就是迭代式。我习惯先跑通迭代式因为调试更容易。4.2 邻接矩阵的三种构建方式以及 sigma 和 threshold 怎么给初值邻接矩阵是整个图卷积模型的“空间先验”它的质量直接影响预测上限。最常见的三种构建方式是距离阈值法、KNN 法和自适应法。构建方式核心思路优点缺点距离阈值法距离小于阈值就建边权重按高斯核衰减物理意义明确实现简单对阈值敏感可能产生孤立节点KNN 法每个节点只连接最近的 K 个节点保证图连通性边数可控K 值需要调连边可能不对称自适应法邻接矩阵作为可学习参数摆脱手工先验能发现隐含依赖训练不稳定需要额外约束距离阈值法里最关键的两个参数是sigma2和threshold。threshold的初值取所有传感器距离的中位数比较稳妥太小会切断有效连接太大则让远处的节点也互相干扰。sigma2控制权重衰减快慢初值可以设在 0.05 到 0.2 之间然后观察训练 loss 是否稳定下降来微调。KNN 法里我一般用 K 10 起步。K 太小图变得稀疏消息传不出去K 太大等价于全连接图卷积退化成全局平均。自适应法在数据量充足时效果最好但训练初期需要配合邻接矩阵正则项否则容易学出一个全是小数的稠密矩阵数值分布很难看。判断邻接矩阵好坏有一个很土但有效的方法训练 20 个 epoch 后把预测曲线和真实曲线叠在一起看趋势。如果高峰时段的预测明显滞后多半是邻接矩阵中关键连接权重偏低或者阈值范围没覆盖到真正的上下游传感器。4.3 归一化策略选 min-max 还是 z-score归一化参数只能从训练集算交通流数据的数值特征对训练稳定性影响极大。速度数据的范围通常 0~100 km/h流量数据可能 0~1000 辆/小时而且分布长尾早高峰时段会出现极端值。源码里的预处理脚本一般会做归一化但用哪种、参数怎么算是复现指标差异的常见来源。# 错误写法: 用全量数据计算归一化参数, 测试集信息掺入训练 mean, std all_data.mean(), all_data.std() train_norm (train_data - mean) / std test_norm (test_data - mean) / std # 正确写法: 只从训练集计算, 验证集和测试集复用这组参数 train_mean train_data.mean() train_std train_data.std() train_norm (train_data - train_mean) / train_std val_norm (val_data - train_mean) / train_std test_norm (test_data - train_mean) / train_std速度数据我一般用 z-score也就是(x - mean) / std因为速度的均值在 60 km/h 左右标准差相对稳定z-score 让数据天然落在 0 附近。流量数据更适合 min-max因为流量有明确的物理下界 0且分布右偏min-max 能保留稀疏性。如果数据里有明显的极端峰值z-score 会对异常值更敏感训练时容易把注意力吸引到少数样本上。另一个细节是反归一化。模型输出的是归一化后的数值评估和可视化之前必须还原成真实值否则 MAE 的单位是“归一化单位”没法跟论文里的 km/h 对比。pred_real pred_norm * train_std train_mean mae np.mean(np.abs(pred_real - test_real))这一点在 5.3 节还会再踩一次数据泄露和单位不统一是“复现不出来论文指标”的两大元凶。5. 图卷积交通流预测避坑5 条翻车记录与排查步骤5.1 预测结果是一条平均曲线Loss 却不低现象训练过程 loss 正常下降画出来的预测曲线却像一条横向拉直的波浪线早晚高峰完全没有凸起。原因交通流数据里平峰时段样本占绝对多数模型用 MSE 作为损失函数时只要输出全天均值平峰部分的误差就很小整体 loss 看起来不错但高峰时段的误差被平均淹没了。解决先按小时维度看分时段误差确认高峰和平峰的 MAE 差距然后给损失函数加权重让高峰时段样本的梯度占比更大。最简单的做法是在训练时给peak_mask对应的样本乘一个系数比如 1.5更稳妥的做法是改用分段损失把样本按速度值分成若干个桶每个桶单独计算误差再平均。5.2 训练到第 3 轮 Loss 变成 NaN现象前几轮 loss 正常第三轮开始突然变成nan重跑一次还是一样。原因邻接矩阵没有做归一化就直接参与矩阵乘法节点度数差异大导致聚合后的特征数值爆炸加上 LSTM 的时间反向传播梯度在几步内就溢出。解决先确认normalize_adj已经执行且邻接矩阵的行和不为零。另一个常见原因是学习率偏高配合torch.nn.utils.clip_grad_norm_把梯度裁剪到 5.0 以内再观察前 5 个 epoch 的 loss 曲线是否平缓下降。5.3 验证集指标虚高归一化的数据泄露现象验证集 MAE 远低于论文报告的数值甚至比训练集还低总感觉哪里不对。原因归一化参数用了全量数据计算验证集和测试集的统计信息混入了训练过程。模型在训练时已经“见过”测试集的分布偏移评估自然失真。解决把归一化参数的计算限定在训练集验证集和测试集直接复用训练集的均值、标准差如果源码里是在整个数据集上先归一化再切分果断改成先切分再归一化。这一步改完指标通常会小幅上升那才是真实水平。5.4 图卷积层数堆到 3 层效果反降现象把图卷积层从 1 层加到 3 层训练 loss 下降更慢验证指标变差。原因图卷积本质上是在做邻居信息的聚合和扩散层数越多每个节点的特征越趋向于整个图的平均状态这就是“过平滑”。交通路网的邻接矩阵本来就稀疏1 层卷积已经能让相邻传感器互通堆深度带来的收益很小。解决深度不是图卷积的主要增益来源不如把算力花在时间维建模和注意力机制上。如果确实需要加深每层之间加残差连接同时配合 Dropout能稍微缓解过平滑。5.5 复现论文指标对不上先查这两处现象严格按照文档说明跑了训练流程最终 MAE 和文档里写的差距明显反复调参也拉不回来。原因最常见的是评估口径不一致——文档可能报告的是单步预测 MAE你评估的是多步预测的平均 MAE数值自然差很多也可能是随机种子不同导致的结果波动或者训练集和测试集的时间划分边界不一样。解决先确认文档里指标的定义是 15 分钟预测还是 60 分钟预测是全部节点平均还是只统计有效节点然后在训练脚本里固定随机种子和文档保持一致的数据切分方式。这两处对齐后大部分“复现不出来”的问题都能收敛。6. 进阶预测曲线可视化与分时段误差拆解6.1 预测曲线对比图要怎么看模型训练完最先要做的是把某个传感器的预测值和真实值画在同一张图里观察趋势是否对齐而不是只看最终指标。Matplotlib 就能胜任这件事。import matplotlib.pyplot as plt node_id 25 # 选一个数据完整的传感器 plt.figure(figsize(12, 4)) plt.plot(test_y[:, node_id], labelobserved, linewidth2) plt.plot(pred_real[:, node_id], labelpredicted, linestyle--) plt.xlabel(time step (5 min)) plt.ylabel(speed (km/h)) plt.legend() plt.grid(alpha0.3) plt.savefig(prediction_curve.png, dpi150)拿到图之后先看三点高峰拐点是否按时出现预测曲线是否比真实曲线平滑太多以及低谷时段是否存在系统性偏差。如果高峰拐点每次都晚一两个时间步出现说明模型对拥堵传播的建模偏慢问题大概率出在邻接矩阵对上下游关系的刻画上。6.2 分时段误差拆解高峰和平峰分开算 MAE全局 MAE 会掩盖模型在高峰时段的真实表现。我习惯把一天划分成早高峰、晚高峰、平峰三个时段分别计算误差。# 假设 test_timestamps 是测试集每个样本对应的真实时间 hours test_timestamps.hour peak_mask ((hours 6) (hours 10)) | ((hours 16) (hours 20)) def mae(y_true, y_pred): return np.mean(np.abs(y_true - y_pred)) print(peak MAE:, mae(test_y[peak_mask], pred_real[peak_mask])) print(off-peak MAE:, mae(test_y[~peak_mask], pred_real[~peak_mask]))如果高峰 MAE 明显高于平峰说明模型对拥堵演化不够敏感改进的方向是在损失函数里提高高峰样本的权重如果平峰 MAE 也高那问题更多出在数据质量或邻接矩阵构建如果两类时段的误差比接近 1.5 倍以内模型整体状态就比较好。6.3 在这套源码基线上继续投入的三个方向跑通并复现了源码指标之后想继续投入可以优先考虑三个方向。第一个是换注意力机制把图卷积里的固定邻接矩阵换成 GAT 的动态注意力权重让模型自己决定邻居的重要程度第二个是加外部特征把天气、节假日、事件等因素拼入节点特征对节假日和突发拥堵的预测会明显改善第三个是改造训练策略用 cosine annealing 学习率调度替代固定学习率通常能在同样 epoch 数下把 MAE 再压低 2%~3%。这套流程走完你会对“空间聚合、时间建模、数据泄露”这三个词有比论文更直观的理解。我自己的习惯是把可视化和分时段评估脚本沉淀到仓库的analysis/目录里每次实验先看这两张图再决定调哪里试错成本比光盯 loss 低得多。希望帮到你。本文还有配套的精品资源点击获取
返回列表