免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

图卷积网络实战:基于PyTorch的城市交通流预测与实现

图卷积网络实战:基于PyTorch的城市交通流预测与实现 简介面向城市交通流预测及图神经网络研究的完整源码包以时间图卷积网络为核心涵盖模型源码、对比实验与文档说明适合交通、计算机、人工智能等专业学生用于毕业设计、课程项目或科研入门。压缩包共164个文件整体约43.12MB主要包含42个Python脚本、42个CSV数据文件、模型权重与训练状态文件h5、checkpoint、meta等、说明文档及结果图像代码目录覆盖T-GCN、A3T-GCN、AST-GCN、KST-GCN四种网络实现并提供HA、ARIMA、SVR、GCN、GRU五种基线模型便于横向对比。所有代码已经过测试运行通过可复现交通速度/流量预测实验文档注释能辅助理解模型差异与调参思路也方便在已有框架上扩展改进。目前已有71人学习下载适合有一定Python基础、希望深入时空图模型的读者也可直接用作课程设计或毕设起点。1. 城市交通流预测为什么最终绕不开图卷积网络城市交通流预测这些年一直是智能交通、网约车调度和信号控制的核心需求但真正落地的门槛却不在模型多深而在“路网结构怎么表达”。普通卷积神经网络假设数据排列在规则的网格上——图像、栅格地图都行可真实路网是典型的非欧几里得结构路口之间是任意拓扑连接传感器距离近不代表它们一定强相关拥堵往往沿道路传播而不是沿经纬度扩散。图卷积网络GCN的出现正是为了解决这类“图结构上的特征提取”问题它直接把邻接矩阵当作先验知识注入网络让模型学到路网上的空间依赖。这篇笔记面向想用 Python 复现城市交通流预测、并把它当作毕设或工程预研原型的读者按“问题建模 → 图卷积实现 → 训练评估 → 踩坑排查”的顺序展开尽量让每一步都能照着跑通而不是停在概念上。说句实在话初次做这个方向的人最容易把精力花在调模型结构上结果数据加载、邻接矩阵构造和归一化这些小环节反而连环翻车。所以这里我会把落地路径、参数经验和常见坑一起讲清楚带你走一遍我实际用下来的那套最小可复现方案。2. 把交通流预测变成图的节点回归任务数据与邻接矩阵怎么准备2.1 先给问题一个数学形式输入是什么预测什么交通流预测通常被定义成一个时空序列预测问题给定过去 T 个时间步、N 个监测点路口、路段或传感器的观测值预测未来 H 个时间步的流量。把这个过程放进图框架里路网表示为 G(V, E, A)其中 V 是节点集合每个节点对应一个监测点E 是边的集合表示道路连通关系A 是 N×N 的邻接矩阵。每个时间步的特征矩阵记为 X∈R^(N×C)C 是每个节点的特征维度。只有流量一个特征时 C1如果还有速度、占有率、天气C 就相应增大。提示这个定义是整个项目的“地基”。后续所有代码的输入输出维度都由它决定建模前先把预测步长 H 想清楚是单步预测预测下一个 5 分钟还是多步预测未来 15/30/60 分钟数据切分方式完全不同。常见的数据集格式是 (B, T, N, C) 或 (B, N, T, C)PyTorch 里我习惯先用 (B, T, N, C) 组织原始序列再在模型内部转置。这个顺序看着小但很多人后面在维度上绕晕建议一开始就在 DataLoader 里固定下来。2.2 邻接矩阵的三种构造方式距离阈值、KNN、路网连通图卷积的“图”不是天然存在的必须由我们自己从监测点坐标或路网拓扑构造邻接矩阵。这是整个项目里最容易被低估的环节——邻接矩阵的质量直接影响模型上限而且一旦建错后面怎么调参都白费。根据我做过的几个项目最常用的有三种构造方式构造方式核心思想优势风险距离阈值法两点距离小于阈值 d 则连边实现简单可解释性强阈值 d 敏感太大图过密、太小图过疏KNN 高斯核法对每个节点取 K 个最近邻边权用高斯核计算保证每个节点都有边适合不均衡分布K 需要试边权对温度参数敏感路网连通法按真实道路连接关系建边物理意义明确数据获取成本高缺路段时矩阵很稀疏工程上最省事的是 KNN 高斯核法纯靠经纬度坐标就能算先计算节点间欧氏距离矩阵对每个节点取 K 个最近邻再套高斯核 exp(-d²/σ²) 生成边权。σ 一般取距离矩阵的标准差或按经验取 0.1 量级。距离阈值法适合传感器布点均匀的数据比如高速公路断面流量路网连通法适合有完整路网拓扑的离线地图数据但缺了拓扑就无从谈起。做毕设或 demo 项目我一般直接用 KNN 高斯核效果稳定、代码量小。另外记得对邻接矩阵做加自环和归一化——即 A A I再除以度矩阵的逆平方根这一步直接放进加载函数里别等到模型里算。2.3 数据切分与归一化的 Python 实现数据准备阶段的代码核心是三件事滑窗切序列、归一化、构造 PyTorch Dataset。下面这段是我常用的滑窗切分代码输入原始流量矩阵 data形状是 (T_total, N)输出 (X, Y) 对。import numpy as np def create_sequences(data, seq_len12, pred_len3): data: (T_total, N) 原始流量矩阵 seq_len: 输入历史窗口长度单位是时间步 pred_len: 预测未来步长比如 3 步 15 分钟如果数据是 5 分钟间隔 返回 X: (样本数, seq_len, N)Y: (样本数, pred_len, N) X, Y [], [] total data.shape[0] for i in range(total - seq_len - pred_len 1): x data[i : i seq_len, :] # 历史窗口 y data[i seq_len : i seq_len pred_len, :] # 未来窗口 X.append(x) Y.append(y) return np.array(X), np.array(Y) # 使用示例 # raw_data: (T_total, N) 的流量值先做归一化再切窗 data np.load(traffic_data.npy) # 归一化min-max 缩放到 [0,1] data_min data.min(axis0, keepdimsTrue) data_max data.max(axis0, keepdimsTrue) data_norm (data - data_min) / (data_max - data_min 1e-8) X, Y create_sequences(data_norm, seq_len12, pred_len3) print(X shape:, X.shape, Y shape:, Y.shape) # 期望输出类似 X: (样本数, 12, N)Y: (样本数, 3, N)逻辑很简单对每个时间点 i取它之前的 12 个时间步作为输入紧接着的 3 个时间步作为预测目标滑窗步长是 1。seq_len12 对应 1 小时历史5 分钟粒度pred_len3 对应预测未来 15 分钟。预测长度再往上翻时误差会明显累积这个在后文避坑部分重点说明。归一化我建议用 min-max把流量缩放到 [0,1] 区间。交通流量天然有强周期性——早高峰、晚高峰、夜间低谷数值范围通常稳定不像股价那样频繁越界min-max 比 z-score 更适配这种有上下界的场景。需要注意两点一是 min 和 max 只用训练集计算验证集和测试集沿用训练集的统计量防止数据泄漏二是归一化公式里加 1e-8 防止某列流量全为 0 时除零报错。这两个细节不注意后面训练出的曲线要么整体偏小要么验证集指标虚高。3. 图卷积是核心谱域 GCN 的切比雪夫近似与 PyTorch 实现3.1 为什么路网必须用图卷积而不是普通 CNN交通流数据的空间依赖并不遵循规则的网格结构。CNN 通过在矩形感受野内做卷积来捕捉局部相关性这对图像没问题但路网上两个位置很近的传感器不一定有道路连通关系反之一条快速路上相隔几个节点的传感器可能才是真正高度相关的。图卷积的思想是把卷积定义在图结构上每个节点聚合邻居节点的信息来更新自身表示聚合权重由邻接矩阵控制。这样空间依赖的学习就和路网拓扑绑定了物理上更说得通。图卷积目前主流有两派谱域方法和空间域方法。谱域方法从图拉普拉斯矩阵的特征分解出发定义卷积经典代表是 ChebNet 和使用一阶近似的 GCN空间域方法则直接在邻居节点上做信息聚合代表是 GraphSAGE、GAT 等。城市交通流预测里谱域 GCN 的计算开销小、实现简单配合一定层数够用GAT 的注意力机制理论上更灵活但训练稳定性差一些数据量小时容易过拟合。我的建议是第一次实现先用谱域 GCN 跑通基线后面如果想冲指标再换 GAT。注意谱域 GCN 里有“过平滑”问题——层数堆到 3 层以上节点特征趋于一致预测曲线变得平缓。交通流预测的图通常节点数几十到几百两层 GCN 是性价比最高的设定。3.2 两层图卷积层的完整实现下面这段代码实现了谱域图卷积的核心逻辑先对邻接矩阵加自环并归一化然后做两次“特征传播 线性变换”。这就是 Kipf 提出的经典 GCN 层也是大多数交通流预测项目的骨架层。import torch import torch.nn as nn import torch.nn.functional as F import numpy as np def normalize_adj(adj): adj: (N, N) 原始邻接矩阵可为 0/1 二值或带权值 返回归一化后的邻接矩阵 A_norm adj adj np.eye(adj.shape[0]) # 加自环 deg adj.sum(axis1) # 度矩阵 deg_inv_sqrt np.power(deg, -0.5) deg_inv_sqrt[np.isinf(deg_inv_sqrt)] 0.0 deg_inv_sqrt np.diag(deg_inv_sqrt) adj_norm deg_inv_sqrt adj deg_inv_sqrt # D^-1/2 A D^-1/2 return torch.FloatTensor(adj_norm) class GCNLayer(nn.Module): def __init__(self, in_dim, out_dim, adj_norm): super().__init__() self.adj adj_norm # (N, N) 归一化邻接矩阵 self.fc nn.Linear(in_dim, out_dim) def forward(self, x): # x: (B, T, N, in_dim) 或 (B, N, in_dim) b, t, n, f x.shape x x.reshape(b * t, n, f) # 核心传播公式: X A_norm X W x torch.einsum(ij,bjf-bif, self.adj, x) x self.fc(x) x x.reshape(b, t, n, -1) return F.relu(x)forward 里先用 einsum 做邻接矩阵和特征矩阵的乘法这一步在节点之间传播信息——每个节点的新特征等于所有邻居特征的加权和权重来自归一化邻接矩阵然后过一个线性层完成特征变换最后接 ReLU 激活。很多教程把 GCN 直接写成“两行代码”但实际落地时维度处理才是最容易出错的地方。这里的 reshape 保证了输入输出都是 (B, T, N, F) 的四维格式正好对接后续的时序模块。如果只需要单步预测adj_norm 可以预先算好传入如果要做多图或多模态融合邻接矩阵也可以作为可学习参数训练但这属于进阶玩法初期不建议碰。初始化的注意力放在 self.fc 的随机种子设置上固定 seed 之后实验的可复现性会大大提高。3.3 从 GCN 到时空网络加上 GRU 或时序卷积GCN 只解决了空间依赖交通流预测还需要同时建模时间依赖。常见做法有两种一是 GCN 和 GRU 堆叠在每个时间步上用 GCN 处理空间结构、GRU 处理时间递进二是采用 STGCN 等模型的思路用时序卷积1D 卷积或 TCN处理时间尺度GCN 处理空间尺度。GRU 结构简单、收敛稳定适合序列长度不长的场景时序卷积并行度高、训练快适合长历史窗口。我这里给一个紧凑的“GCN GRU”组合模型这种结构的代码量最小、最容易调通。class GCN_GRU(nn.Module): def __init__(self, node_num, in_dim, hidden_dim, out_dim, adj_norm, seq_len12, pred_len3): super().__init__() self.node_num node_num self.gcn1 GCNLayer(in_dim, hidden_dim, adj_norm) self.gcn2 GCNLayer(hidden_dim, hidden_dim, adj_norm) self.gru nn.GRU(hidden_dim, hidden_dim, batch_firstTrue) self.fc_out nn.Linear(hidden_dim, pred_len) def forward(self, x): # x: (B, T, N, 1) b, t, n, f x.shape x self.gcn1(x) # GCN 空间特征提取 x self.gcn2(x) x x.permute(0, 2, 1, 3) # (B, N, T, hidden_dim) x x.reshape(b * n, t, -1) # 每个节点独立过 GRU gru_out, _ self.gru(x) # (B*N, T, hidden_dim) last gru_out[:, -1, :] # 取最后一时间步 out self.fc_out(last) # (B*N, pred_len) out out.reshape(b, n, -1) # (B, N, pred_len) return out.permute(0, 2, 1) # (B, pred_len, N) # 实例化模型 adj_norm normalize_adj(adj) # adj 是 2.2 节构造的邻接矩阵 model GCN_GRU(node_numadj.shape[0], in_dim1, hidden_dim64, out_dimpred_len, adj_normadj_norm, seq_len12, pred_len3)时序上的处理逻辑是GCN 先逐时间步提取空间特征得到 (B, T, N, hidden_dim)然后转置成 (B, N, T, hidden_dim)reshape 成 (B*N, T, hidden_dim)让每个节点独立过 GRU最后取 GRU 最后一个时间步的输出过全连接层得到未来 pred_len 步的预测值。这里的关键设计是每个节点共享一个 GRU而不是为每个节点单独建 GRU这样既参数共享、又保留了节点之间的差异性。如果你喜欢纯卷积结构也可以把 GRU 换成两层 Conv1d效果各有千秋。对于小数据集GRU 更稳对于长序列大数据时序卷积更快。不要一上来就上 Transformer 或扩散图卷积——先跑通这个最小模型拿到一个合理的评测基线再谈结构升级。4. 训练、评估与调参把预测误差压到可信区间4.1 训练循环与早停机制模型搭好之后训练本身没有太多“黑科技”但训练代码的组织方式直接决定你后期调参的效率。我习惯把训练循环、早停、模型保存写进一个函数每次实验只需调整超参数入口。下面这段代码给出完整训练骨架直接针对流量数据做了 NaN 屏蔽处理——传感器故障导致的缺失值在交通流量数据里非常常见不屏蔽的话梯度会被污染。import torch.optim as optim def train_model(model, train_loader, val_loader, epochs100, lr0.001, patience10): optimizer optim.Adam(model.parameters(), lrlr) scheduler optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience5 ) best_val_loss float(inf) bad_epochs 0 for epoch in range(epochs): model.train() train_loss 0.0 for xb, yb in train_loader: # xb: (B, T, N, 1) yb: (B, pred_len, N) mask ~torch.isnan(yb) # 缺失值掩码 yb torch.nan_to_num(yb, nan0.0) # 用 0 填充缺失值 pred model(xb) loss ((pred - yb) * mask).pow(2).sum() / (mask.sum() 1e-8) optimizer.zero_grad() loss.backward() optimizer.step() train_loss loss.item() model.eval() val_loss 0.0 with torch.no_grad(): for xb, yb in val_loader: mask ~torch.isnan(yb) yb torch.nan_to_num(yb, nan0.0) pred model(xb) loss ((pred - yb) * mask).pow(2).sum() / (mask.sum() 1e-8) val_loss loss.item() val_loss / len(val_loader) scheduler.step(val_loss) # 早停与模型保存 if val_loss best_val_loss: best_val_loss val_loss bad_epochs 0 torch.save(model.state_dict(), best_model.pth) print(fEpoch {epoch}: train_loss{train_loss:.4f}, val_loss{val_loss:.4f} *) else: bad_epochs 1 if bad_epochs patience: print(fEarly stop at epoch {epoch}) break注意loss 计算时必须把 NaN 先填成 0再乘 mask 屏蔽掉对应位置否则 NaN 会传染整个 batch 的梯度计算。这是交通流数据训练第一天就会踩的坑。模型保存策略选了“验证集最优保存”而不是“最后一轮保存”。早停 patience 设为 10配合学习率衰减能有效避免后期振荡。如果验证集 loss 连续 10 个 epoch 不下降就停掉训练回到最优模型继续评估。4.2 评估指标与反归一化评估指标的选取决定了你向别人汇报结果时的说服力。交通流预测领域最常用的三个指标是 MAE平均绝对误差、RMSE均方根误差和 MAPE平均绝对百分比误差。其中 MAE 是衡量平均偏差的直观指标RMSE 会放大较大误差的影响MAPE 则能反映相对误差但对接近 0 的流量值非常敏感流量为 0 时 MAPE 直接爆炸。常规做法是把这三项全部算出来再根据预测步长分别展示。评估代码里最容易犯的错是忘记反归一化。训练时数据被缩放到 [0,1]模型的输出也在 [0,1] 区间直接在这上面算 MAE 得到一个很小的数看似好看实际没有物理意义。必须把预测值和真实值同时反归一化回原始流量单位再计算指标。def evaluate_metrics(pred_norm, true_norm, data_min, data_max): pred_norm/true_norm: (B, pred_len, N) 归一化后的数据 data_min/data_max: (1, N) 训练集的每列最小/最大值 返回 MAE/RMSE/MAPE单位为原始流量值 pred pred_norm * (data_max - data_min) data_min true true_norm * (data_max - data_min) data_min mask true 1e-6 # 过滤流量接近 0 的点避免 MAPE 爆炸 mae np.abs(pred - true)[mask].mean() rmse np.sqrt(((pred - true) ** 2)[mask].mean()) mape (np.abs(pred - true) / true)[mask].mean() * 100 return mae, rmse, mape过滤掉流量接近 0 的样本再算 MAPE是行业内的常见操作。流量为 0 意味着该时段该路段几乎没车预测差 1 辆车的绝对误差就会导致 MAPE 到无穷大这对模型评价没有参考意义。报告结果时一定要注明过滤阈值否则别人对比指标时会觉得莫名其妙。4.3 三个必调参数的经验区间超参数调优不用玄学但有一个“先粗后细”的次序。我一般按下面三个优先级去调参数经验区间调参方向说明学习率1e-4 ~ 1e-2先 1e-3 起步loss 不降就减半Adam 优化器下 1e-3 是交通流任务的安全起点hidden_dim32 ~ 128从小往大调观察验证集 loss节点数多时取大值防止容量不足seq_len6 ~ 24以 15/30/60 分钟为粒度试太长会引入噪声太短学不到周期性学习率是最敏感的超参数1e-2 很容易发散1e-4 收敛太慢。我习惯在训练循环里打印前 5 个 epoch 的 loss 曲线来判断学习率是否合适——第一轮就从 1e4 掉到 1e2 量级说明学习率偏大下降平缓则偏小。hidden_dim 和模型容量直接挂钩节点数 64 时 hidden_dim64 是个不错的默认值。seq_len 的选取要看数据粒度5 分钟数据预测未来 15 分钟历史窗口取 12 步1 小时通常比取 3 步效果好因为早晚高峰的梯度变化需要足够长的上下文才看得出来。调参的时候记住固定随机种子不然每次实验的网络初始化不同对比结果没有意义。PyTorch 里设置torch.manual_seed(42)加np.random.seed(42)CUDA 环境下再加torch.cuda.manual_seed_all(42)。5. 交通流预测的五个高频踩坑现象、排查与解决5.1 损失正常下降但预测曲线几乎是一条平线这个现象非常经典特征在于训练集 loss 在降验证集指标看起来还行但把预测值和真实值画在一起时预测曲线像被“压缩”了一样贴近均值。原因通常有两个一是归一化时用了全数据的 min 和 max导致预测目标范围过宽模型只学到均值回归二是邻接矩阵包含了大量弱连接图卷积层把空间信息“过分平滑”了所有节点的表示趋于一致。解决办法是先检查归一化统计量只拿训练集计算 min/max测试集沿用同一组值。然后看邻接矩阵的稀疏度——如果平均每个节点的邻居超过 10 个建议减小 K 值或增大距离阈值让图更稀疏一些。最后检查 GCN 层数两层是安全的超过三层大概率过平滑。5.2 验证集效果“逆天”实则是数据泄漏这里说的数据泄漏指两种常见情况一是切窗时把目标时间步也放进了输入窗口模型等于拿标准答案做题二是归一化用了全数据的统计量测试集信息提前进入了训练过程。现象是验证集 MAE 不到 1但模型换到真实场景预测立刻“翻车”。排查方法很简单打印一组训练样本的输入和输出时间范围肉眼确认输出起始时间严格晚于输入结束时间。归一化统计量只从训练集计算测试集标准化时直接复用训练集的 min/max。这个坑看似低级但在实际代码里极易发生——尤其当原始数据已经有人提前做过归一化处理时我们往往意识不到统计量来自全量数据。5.3 训练时 loss 出现 NaN梯度爆炸或数据缺失造成流量数据中出现 NaN 的原因无外乎两条传感器上报异常导致原始数据缺失或者学习率太大导致梯度爆炸。前者表现为某个节点整段时间都是 NaN后者表现为 loss 经过几十个 epoch 后突然从 0.1 级跳到 NaN。解决办法是两路并进。数据层面接收原始数据后先检查每列 NaN 比例超过 30% 的传感器节点建议整列剔除低于 30% 的用前向填充或线性插值补齐。训练层面把梯度裁剪挂进训练循环torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0)同时学习率上限压到 5e-3 以内。这两个手段同时用上99% 的 NaN 都能解决。5.4 预测未来 60 分钟时误差骤增多步预测的累积误差问题多步预测的误差随预测步长增加而递增是正常现象但如果 15 分钟预测 MAE 是 860 分钟预测 MAE 直接到 40那就不是模型能力问题而是误差累积机制被低估了。交通流预测里模型在单步预测上表现良好往往是因为它学到了“惯性”——下一步跟上一步差不多但时间跨度拉长后这种惯性成了严重的偏差来源。针对这个问题的常见方案有三个。一是在训练时引入多步损失权重让模型对远期预测给予更多关注二是采用序列生成策略把上一步的预测结果作为下一步的输入增强模型对误差的适应能力即 Scheduled Sampling但对数据量有要求三是把多步预测分解成多个单步预测器每个预测器分管一个预测步长。实际项目中我用得最多的是方案三实现简单、可解释性强MAE 普遍能再降 5% 到 10%。5.5 预测值整体偏大或偏小问题出在反归一化和 mask 不匹配训练时 NaN 值被填充为 0 再乘 mask预测阶段如果直接对原始输出反归一化填充位置的 0 会被放大成负数流量。现象就是预测曲线在某些节点上出现明显的“向下毛刺”负流量非常扎眼。解决方法是评估时同样先构建 mask反归一化后把 mask 外的位置标记为无效。更进一步反归一化的统计量必须和训练时一致——如果训练时用的是 (0, 1) 区间的 min-max反归一化也要用同一组 min/max不能重新在测试集上计算。这是一个细节但往往是最终汇报时被别人质疑“你这负流量什么意思”的根源。6. 进阶验证消融实验与预测曲线可视化当你跑通基线模型后下一步不是盲目换模型而是先做消融实验弄清楚每个模块到底贡献了多少提升。我的做法是固定数据集和超参数依次做四个变体去掉图结构把 GCN 换成 MLP、去掉时序模块直接对输入序列取均值做预测、只用 GCN 不用 GRU、以及完整模型。结果对比表长这样模型变体MAERMSE趋势MLP无图结构高高无法捕捉空间传播只用 GCN无时序中中空间好但时间依赖弱GCN GRU完整最低最低时空特征都捕捉到消融实验不只是为了写论文它真正的作用是验证你构造的邻接矩阵和时序模块是否在发挥正向作用。如果去掉 GCN 后效果反而更好说明邻接矩阵构建有问题或者数据本身没有明显的空间传播关系——这种发现能帮你省下大量的无用调参时间。预测结果可视化是最后一道验证工序。把预测曲线和真实曲线画在同一张图里按 5 分钟粒度展示 24 小时的拟合情况早晚高峰处有没有系统性滞后一目了然。结题或汇报时这张图的影响力远大于一堆指标数字。我现在固定的收尾习惯是每次跑完实验把 best_model.pth 的参数、归一化统计量、邻接矩阵和预测可视化结果放进同一个实验目录标注数据来源和时间范围。这个习惯帮我省了无数“这是哪个模型跑出来的”的追问时间。这个方向最终能不能落地考验的往往不是模型有多新而是数据准备和验证流程有多扎实。希望这篇笔记能帮你在图卷积交通流预测这条路上少走几步弯路。本文还有配套的精品资源点击获取
返回列表