
最近总有朋友在后台问我图卷积神经网络GCN到底怎么用在交通流量时空预测里论文里的公式看懂了但真要自己写出一个能跑的程序总觉得中间隔着一条河。这篇文章我想用一个完整的模拟路网流量预测项目把从数据构造、邻接矩阵生成、GCN模型设计、训练评估到最后的GUI界面全部串起来代码都是可以直接复制运行的程度。适合已经会用PyTorch做基础建模、但对图数据还不太熟的读者如果你刚接触GCN我尽量把每个关键点都讲成大白话先搞懂“为什么”再去写代码。这个项目我会用10个节点模拟一个小型路网每个节点代表一个交通检测器节点之间的连边代表道路连接关系数据是自己生成的带周期性和空间传播效应的流量序列。这样做的最大好处是结果完全可复现你不用花时间去申请外部数据集也能把整套GCN流程跑通。看清楚这套流程之后再换真实路网数据本质上只是替换数据加载和邻接矩阵构造的部分。1. 为什么交通流量预测绕不开图卷积先把问题想清楚1.1 路网不是网格传统CNN天然吃亏传统卷积神经网络CNN在图像上表现很好因为图像是一个规则的二维网格像素之间的关系是固定且局部的卷积核可以按照从左到右、从上到下的顺序扫过去。但交通路网完全不是这个结构。一个路口的相邻路段不是“上下左右”的像素关系而是拓扑连接关系两个在欧几里得距离上很近的路口可能因为中间有隔离带或者单行道实际交通影响反而很弱。这就是问题的核心如果非要把路网展平成一张网格图片再用CNN去处理会硬生生丢失掉路网的拓扑结构信息。举个最直接的例子某个路口堵车了下游三个路口的流量都会受到冲击但它们在图上的连接关系可能是完全不同的权重。CNN的卷积核做不到按“图连接”去做信息聚合它只能按照固定网格位置去扫这样的空间建模对路网来说是错位的。1.2 时空预测的本质空间依赖和时间演化要一起抓交通流量预测之所以叫“时空预测”是因为它包含两个维度。时间维度很好理解早高峰、晚高峰、节假日这些周期规律都写在历史流量曲线里一段序列预测下一段序列这本质上是时间序列预测问题。空间维度则是路网的传播效应上游堵车会向下游扩散相邻路口的流量往往同步波动。GCN解决的是空间依赖。它的核心思想是让每个节点通过图结构去聚合邻居节点的信息每一层图卷积都相当于做了一次“邻居信息交换”。而时间维度我会用一维卷积来处理做局部的时序特征提取。相比LSTM/GRU一维卷积实现更简单训练稳定而且它的感受野可以靠堆叠层数来控制。整个模型结构不复杂但恰恰能演示清楚“时间卷积GCN空间卷积”这个最常见的时空预测组合。1.3 项目整体流程与模型输入输出约定整个项目包括六个环节构造模拟路网、生成流量序列、构造邻接矩阵并归一化、滑动窗口划分训练集、搭建GCN模型训练、GUI加载模型做预测。模型输入是过去P个时间片的流量数据形状是(节点数, P)输出是未来Q个时间片的流量预测形状是(节点数, Q)。在项目中我取P12过去12个时间片Q6预测未来6个时间片时间片单位可以理解为5分钟这样刚好覆盖一个小时的预测范围。这里要特别说明一点交通流量预测的输入输出不能像图像分类那样把样本随机打乱。交通数据是强时序依赖的打乱会让模型偷看到“未来”的信息导致验证指标虚高。所以我按时间顺序做7:2:1的划分前面的70%做训练紧跟着20%做验证最后10%做测试这样才符合真实场景下的评估逻辑。2. 数据准备先造一个能复现的实验场再谈模型2.1 用随机拓扑生成模拟路网和带传播效应的流量序列我先用networkx生成一个10节点的小型路网拓扑每节点连接3个邻居随机重连概率设为0.2这样既能保证每个节点有邻居又不会完全规则。然后为每个节点生成一个带周期、相位和噪声的流量序列。为了让模拟数据更贴近真实交通我还额外加了一个空间传播项如果两个节点之间有边那么上游节点前几个时刻的流量会对下游节点当前时刻产生正贡献模拟拥堵从上游传播到下游的效果。import numpy as np import networkx as nx np.random.seed(42) num_nodes 10 T 1200 # 生成一个小世界图作为路网拓扑 G nx.watts_strogatz_graph(num_nodes, 3, 0.2, seed42) adj nx.to_numpy_array(G) # 生成基础周期流量序列 raw_flow np.zeros((num_nodes, T)) for i in range(num_nodes): t np.arange(T) phase np.random.uniform(0, 2 * np.pi) raw_flow[i] 50 20 * np.sin(2 * np.pi * t / 120 phase) # 添加邻居传播效应让边连接的下游节点受到上游滞后影响 flow raw_flow.copy() for edge in G.edges(): u, v edge for lag in range(1, 4): flow[v, lag:] 5 * raw_flow[u, :-lag] / lag flow[u, lag:] 5 * raw_flow[v, :-lag] / lag # 加噪声 flow np.random.normal(0, 2.0, sizeflow.shape)这个传播项的写法不算精细但足够制造出“相邻节点序列相关”的效果让GCN有东西可以学。如果你要换真实数据集只需要把flow替换成真实检测器的流量矩阵把adj替换成根据路网距离生成的邻接矩阵即可。2.2 邻接矩阵的三种构造方式和归一化细节邻接矩阵是图卷积的“骨架”。最朴素的做法是01矩阵两个节点有道路连接就为1否则为0进阶做法是用距离倒数加权两个检测器距离越近权重越大还有一种做法是用历史流量的皮尔逊相关系数构造邻接矩阵相关性越高边的权重越大。项目里先用01拓扑矩阵因为代码最直观也方便讲解归一化。在把邻接矩阵喂给GCN之前必须做归一化不然那些度数高的节点会在聚合时被过分放大。最常用的归一化是加自环之后再对称归一化公式是[ \tilde{A} D^{-1/2} (A I) D^{-1/2} ]其中(D)是度矩阵(I)是单位矩阵。加自环是为了让节点在聚合邻居信息的同时保留自己的特征不加自环的话每一层GCN都只“听邻居说话”自己的原始特征会丢掉。def normalize_adj(A): A A np.eye(A.shape[0]) deg np.sum(A, axis1) d_inv_sqrt np.power(deg, -0.5) d_inv_sqrt[np.isinf(d_inv_sqrt)] 0.0 D_inv_sqrt np.diag(d_inv_sqrt) return D_inv_sqrt A D_inv_sqrt adj_norm normalize_adj(adj) np.save(adj.npy, adj_norm)对节点多的大规模路网建议把归一化后的邻接矩阵转成稀疏矩阵可以省内存也能加速。这里节点数只有10直接用稠密矩阵做矩阵乘法代码简单一些。2.3 滑动窗口划分样本、标准化的正确姿势给定时间序列flow用长度为P的滑动窗口切出样本每个样本的标签是紧接着的Q个时刻。这里我把flow形状固定为(num_nodes, T)所以切出来的样本形状是(num_nodes, P)标签形状是(num_nodes, Q)。P, Q 12, 6 X, Y [], [] for i in range(T - P - Q 1): x flow[:, i:iP] y flow[:, iP:iPQ] X.append(x) Y.append(y) X np.array(X) # shape: (样本数, 节点数, 历史步数) Y np.array(Y) # shape: (样本数, 节点数, 预测步数)标准化这一步很多人会踩坑。如果直接在整个数据集的均值和方差上标准化训练集和测试集之间会存在信息泄漏测试结果会偏乐观。正确做法是先按时间顺序切分原始样本再使用训练集的统计量去标准化训练、验证和测试集。n_samples len(X) train_end int(n_samples * 0.7) val_end int(n_samples * 0.9) train_X_raw, train_Y_raw X[:train_end], Y[:train_end] val_X_raw, val_Y_raw X[train_end:val_end], Y[train_end:val_end] test_X_raw, test_Y_raw X[val_end:], Y[val_end:] # 只用训练集计算均值和标准差 mean train_X_raw.mean(axis(0, 2), keepdimsTrue) # shape: (1, 节点数, 1) std train_X_raw.std(axis(0, 2), keepdimsTrue) 1e-6 def normalize(X): return (X - mean) / std train_X, train_Y normalize(train_X_raw), normalize(train_Y_raw) val_X, val_Y normalize(val_X_raw), normalize(val_Y_raw) test_X, test_Y normalize(test_X_raw), normalize(test_Y_raw) np.savez(test_data.npz, test_Xtest_X, test_Ytest_Y, meanmean, stdstd)标准化后模型的训练会稳定很多。如果不标准化均方误差会被流量本身的数值大小比如50到70的范围放大梯度更新也容易震荡。2.4 自定义Dataset与DataLoaderPyTorch的DataLoader需要一个Dataset对象我自定义一个简单的类。这里要再次强调输入形状__getitem__返回的x是(num_nodes, P)y是(num_nodes, Q)batch之后模型拿到的是(batch_size, num_nodes, P)。import torch from torch.utils.data import Dataset, DataLoader class TrafficDataset(Dataset): def __init__(self, X, Y): self.X torch.FloatTensor(X) self.Y torch.FloatTensor(Y) def __len__(self): return len(self.X) def __getitem__(self, idx): return self.X[idx], self.Y[idx] train_dataset TrafficDataset(train_X, train_Y) val_dataset TrafficDataset(val_X, val_Y) test_dataset TrafficDataset(test_X, test_Y) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue) val_loader DataLoader(val_dataset, batch_size64, shuffleFalse) test_loader DataLoader(test_dataset, batch_size64, shuffleFalse)每个batch里所有节点同时参与计算GCN的图结构信息通过adj传入模型不依赖batch内样本的顺序。3. 模型搭建把GCN的图卷积和时序组件拼到一起3.1 GCN的谱域公式先不碰傅里叶用消息传递来理解很多教程一上手就讲图拉普拉斯、傅里叶变换把不少同学劝退了。应用层面我们可以换一个等价视角GCN每一层在做的事就是让每个节点把自己当前的特征向量发给自己所有邻居同时从邻居那里接收特征然后带着权重做一次线性变换。公式可以写成[ H^{(l1)} \sigma( \tilde{A} H^{(l)} W^{(l)} ) ]其中\tilde{A}就是前面归一化后的邻接矩阵H^{(l)}是第l层所有节点的特征矩阵W^{(l)}是该层的可学习权重。你可以把它类比成“把每条边的消息汇总到目标节点”只不过这里的汇总权重由\tilde{A}决定而W是需要训练的参数。有了这层理解GCN层代码就很简单。输入x的形状是(batch, num_nodes, in_dim)用einsum将邻接矩阵作用到节点维度上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.weight nn.Parameter(torch.FloatTensor(in_dim, out_dim)) self.bias nn.Parameter(torch.FloatTensor(out_dim)) self.reset_parameters() def reset_parameters(self): stdv 1.0 / np.sqrt(self.weight.size(1)) self.weight.data.uniform_(-stdv, stdv) self.bias.data.uniform_(-stdv, stdv) def forward(self, x, adj): # x: (batch, num_nodes, in_dim) # adj: (num_nodes, num_nodes) support torch.matmul(x, self.weight) # (batch, num_nodes, out_dim) out torch.einsum(ij,bjf-bif, adj, support) return out self.bias堆叠两层GCN每个节点就能感受到二阶邻居的信息。对10个节点的路网来说两层已经足够再深反而容易过平滑——所有节点的特征趋于一致流量差异就被抹平了。3.2 时间维度的处理每个节点共享同一个时序卷积我的输入X里每个节点有P12个历史时间片如果直接把整条序列压平再进GCN会丢失局部时间顺序。这里用一维卷积提取时间特征把(batch, num_nodes, P)变形为(batch*num_nodes, 1, P)时间卷积在最后一个维度上滑动每个节点共享同一个卷积核既高效又能保证同一路网里所有节点使用相同的时序特征提取器。class TimeConv(nn.Module): def __init__(self, in_steps, out_dim): super().__init__() self.conv nn.Sequential( nn.Conv1d(1, 16, kernel_size3, padding1), nn.ReLU(), nn.Conv1d(16, out_dim, kernel_size3, padding1), ) def forward(self, x): # x: (batch, num_nodes, in_steps) batch, num_nodes, _ x.shape y x.reshape(batch * num_nodes, 1, -1) y self.conv(y) # (batch*num_nodes, out_dim, in_steps) y y.mean(dim-1) # 全局平均池化把时间维压缩成一个向量 return y.view(batch, num_nodes, -1)用卷积层而不是LSTM是为了让时序组件保持轻量。如果你在真实项目里发现时间依赖超过小时级别可以把TimeConv替换成GRU或者Transformer块但GCN部分不用动。3.3 完整模型结构与损失函数完整模型分三步先用时间卷积把历史序列压成每个节点的特征向量再经过两层GCN在路网上交换空间信息最后把全部节点的特征拼起来通过全连接层输出num_nodes * Q个数reshape成(batch, num_nodes, Q)。class GCNTraffic(nn.Module): def __init__(self, num_nodes, in_steps, out_steps, hidden_dim32): super().__init__() self.num_nodes num_nodes self.out_steps out_steps self.time_conv TimeConv(in_steps, hidden_dim) self.gcn1 GCNLayer(hidden_dim, hidden_dim) self.gcn2 GCNLayer(hidden_dim, hidden_dim) self.fc nn.Linear(hidden_dim * num_nodes, out_steps * num_nodes) def forward(self, x, adj): # x: (batch, num_nodes, in_steps) h self.time_conv(x) # (batch, num_nodes, hidden_dim) h F.relu(self.gcn1(h, adj)) h F.relu(self.gcn2(h, adj)) h h.reshape(h.size(0), -1) out self.fc(h) return out.view(-1, self.num_nodes, self.out_steps)损失函数就用均方误差MSE对所有节点和所有预测步求平均。多步预测任务里如果不同预测步的难度不同也可以给更远的步数加更高的权重但在这个项目里统一MSE已经够稳定。3.4 训练循环、学习率调度与早停策略训练循环看起来和普通PyTorch分类模型差不多但有两个细节要重视。一个是adj要转成torch.FloatTensor且整个训练过程中保持不变另一个是每个epoch都应在验证集上算一次loss用它来判断是否降低学习率或提前停止。adj_tensor torch.FloatTensor(adj_norm) model GCNTraffic(num_nodes, P, Q, hidden_dim32) optimizer torch.optim.Adam(model.parameters(), lr0.005, weight_decay1e-4) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size30, gamma0.5) loss_fn nn.MSELoss() best_val_loss float(inf) patience, wait 50, 0 for epoch in range(100): model.train() train_loss 0.0 for xb, yb in train_loader: out model(xb, adj_tensor) loss loss_fn(out, yb) optimizer.zero_grad() loss.backward() optimizer.step() train_loss loss.item() * len(xb) model.eval() val_loss 0.0 with torch.no_grad(): for xb, yb in val_loader: out model(xb, adj_tensor) val_loss loss_fn(out, yb).item() * len(xb) train_loss / len(train_dataset) val_loss / len(val_dataset) scheduler.step() if val_loss best_val_loss - 1e-4: best_val_loss val_loss wait 0 torch.save(model.state_dict(), model_best.pt) else: wait 1 if wait patience: print(fearly stop at epoch {epoch}) break这里的patience设为50是偏保守的。对于模拟数据通常20到30个epoch就能看到验证loss开始稳定下降如果一直不降优先检查数据标准化和邻接矩阵不要急着调参。4. 评估指标与可视化模型好坏不只看loss长什么样4.1 MAE、RMSE和预测相关性的计算代码训练结束后用测试集做评估。因为训练时数据被标准化过测试集上的预测结果需要先反标准化再和真实值计算指标这样指标的单位才是真实的流量单位我这里模拟的是“辆/5分钟”。def evaluate(model, loader): model.eval() preds, targets [], [] with torch.no_grad(): for xb, yb in loader: out model(xb, adj_tensor) preds.append(out.numpy()) targets.append(yb.numpy()) preds np.concatenate(preds, axis0) targets np.concatenate(targets, axis0) # 反标准化 preds preds * std mean targets targets * std mean mae np.mean(np.abs(preds - targets)) rmse np.sqrt(np.mean((preds - targets) ** 2)) return mae, rmse, preds, targets mae, rmse, test_pred, test_true evaluate(model, test_loader) print(fMAE{mae:.4f}, RMSE{rmse:.4f})MAE更直观异常点对它的影响小一些RMSE对大的预测偏差更敏感如果你特别不能容忍某个路口预测完全跑偏就要多看RMSE。交通预测领域这两个指标基本是标配再进阶一点可以看MAPE平均绝对百分比误差但流量接近零时MAPE会被极端值干扰需要先做阈值过滤。4.2 预测曲线和误差分布图评估完指标我习惯画出三个图第一个是某个节点在测试集上“真实流量 vs 预测流量”的曲线第二个是误差分布直方图第三个是“预测值-真实值”散点图。散点图能直观看出模型是否存在系统性偏差比如低流量时段高估、高峰时段低估。import matplotlib.pyplot as plt node_id 3 sample_id 20 plt.figure(figsize(10, 4)) plt.plot(test_true[sample_id, node_id], labelground truth, linewidth2) plt.plot(test_pred[sample_id, node_id], labelprediction, linestyle--) plt.xlabel(time step) plt.ylabel(flow) plt.legend() plt.title(fnode {node_id} prediction) plt.tight_layout() plt.savefig(pred_curve.png, dpi150) plt.show()在模拟数据上正常训练完MAE一般能控制在2.0到3.0之间而流量本身的波动幅度大约在20到40之间说明模型基本抓住了周期规律和空间传播效应。如果你把图卷积去掉只保留时间卷积和全连接MAE通常会变差不少这也算是GCN这个组件存在的意义。5. GUI界面设计让模型从命令行“长”出交互界面5.1 界面布局与交互逻辑命令行训练模型可以验证算法但要真正给非技术同事用还是得有一个图形界面。我选择Tkinter做界面原因很简单Python自带不需要装Qt相关依赖配合matplotlib就能把预测曲线嵌进窗口。界面的交互逻辑分成三步用户输入要查看的节点编号和测试样本编号点击“加载模型”读取训练阶段保存的model_best.pt、adj.npy和归一化参数点击“预测”按钮界面取测试集中对应样本用模型预测未来6个时间片并把历史流量、预测流量、真实流量画在同一张图上。5.2 界面组件和模型联动代码这里给出GUI的核心代码。为了控制在文章篇幅里能读完我把界面布局和预测逻辑放在一起但保留完整可运行结构。import tkinter as tk from tkinter import ttk import numpy as np import torch from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.figure import Figure class TrafficGUI: def __init__(self, root): self.root root root.title(GCN Traffic Flow Prediction) self.model None self.adj None self.mean None self.std None self.test_X None self.test_Y None # 参数区 param_frame ttk.Frame(root) param_frame.pack(pady10) ttk.Label(param_frame, textnode id (0-9):).grid(row0, column0) self.node_entry ttk.Entry(param_frame, width6) self.node_entry.insert(0, 3) self.node_entry.grid(row0, column1) ttk.Label(param_frame, textsample index:).grid(row0, column2) self.sample_entry ttk.Entry(param_frame, width8) self.sample_entry.insert(0, 20) self.sample_entry.grid(row0, column3) self.load_btn ttk.Button(param_frame, textLoad Model, commandself.load_model) self.load_btn.grid(row0, column4, padx5) self.predict_btn ttk.Button(param_frame, textPredict, commandself.predict) self.predict_btn.grid(row0, column5, padx5) self.predict_btn.state([disabled]) # 绘图区 self.fig Figure(figsize(8, 4), dpi100) self.ax self.fig.add_subplot(111) self.canvas FigureCanvasTkAgg(self.fig, masterroot) self.canvas.get_tk_widget().pack() self.status_label ttk.Label(root, textwaiting for model...) self.status_label.pack(pady5)load_model方法负责加载训练产物。如果这些文件不存在会在状态栏给出提示不会直接崩溃。5.3 加载模型和数据、执行预测的完整代码加载模型时要把训练阶段保存的所有关键对象都读回来包括模型结构参数、归一化后的邻接矩阵、标准化统计量和测试集样本。模型结构需要重新实例化再load_state_dict。def load_model(self): try: data np.load(test_data.npz) self.test_X data[test_X] self.test_Y data[test_Y] self.mean data[mean] self.std data[std] self.adj torch.FloatTensor(np.load(adj.npy)) model GCNTraffic(num_nodesself.test_X.shape[1], in_stepsself.test_X.shape[2], out_stepsself.test_Y.shape[2], hidden_dim32) model.load_state_dict(torch.load(model_best.pt, map_locationcpu)) model.eval() self.model model self.predict_btn.state([!disabled]) self.status_label.config(textmodel loaded) except Exception as e: self.status_label.config(textfload error: {e}) def predict(self): if self.model is None: return node_id int(self.node_entry.get()) sample_id int(self.sample_entry.get()) x self.test_X[sample_id] y self.test_Y[sample_id] xb torch.FloatTensor(x[None, ...]) with torch.no_grad(): pred self.model(xb, self.adj)[0].numpy() # 反标准化 pred pred * self.std self.mean truth y * self.std self.mean # 绘制历史、预测和真实值 history x[:, -6:] history history * self.std.squeeze(-1) self.mean.squeeze(-1) self.ax.clear() t_his list(range(-len(history), 0)) t_pred list(range(0, self.model.out_steps)) self.ax.plot(t_his, history[node_id], colorgray, labelhistory) self.ax.plot(t_pred, truth[node_id], colorblack, markero, labelground truth) self.ax.plot(t_pred, pred[node_id], colorred, markerx, linestyle--, labelprediction) self.ax.axvline(x0, colorblue, linestyle:) self.ax.set_xlabel(relative time step) self.ax.set_ylabel(flow) self.ax.legend() self.canvas.draw() self.status_label.config(textfnode {node_id}, sample {sample_id} done)这里值得强调的是历史序列部分模型只输入了过去12个时间片但为了图像更清楚我展示了最后6个历史点用灰色虚线表示。真实值用黑色实线预测值用红色虚线蓝色竖线表示当前时刻这样一眼就能看出预测值和真实值之间的偏差方向。6. 踩坑记录与调参心得这些细节比模型结构更影响结果6.1 邻接矩阵归一化的两个隐藏坑第一个坑是没有加自环。测试时我把归一化前的邻接矩阵直接用对称归一化结果训练loss下降很慢而且验证指标始终上不去。原因就是每个节点只聚合了邻居的信息自己的上一时刻特征没有参与更新信息流断了。解决办法就是在归一化前先加单位矩阵。第二个坑是归一化后矩阵不再具有“行和等于1”的性质。对称归一化是D^{-1/2} A D^{-1/2}如果后面想解释GCN是在做“加权平均”更直观的选择是随机游走归一化D^{-1} A。两种归一化在论文里都很常见但对应的特征尺度不同换了归一化之后学习率也要跟着调。我的经验是小规模路网用对称归一化加ReLU训练更稳定。6.2 训练震荡和loss不降怎么排查训练过程中如果loss像过山车一样来回跳第一步检查学习率。Adam默认的0.001在不少图上够用但图卷积层数少、节点规模小的时候0.005反而收敛更快。如果学习率降到1e-4还是震荡就要怀疑输入数据有没有做标准化。交通流量原始值经常在30到100之间波动没标准化时MSE的梯度尺度会忽大忽小这是最常见的训练不动原因。还有一个容易被忽略的点验证集和测试集划分后如果验证loss明显高于训练loss而训练loss一直在降说明模型在“背”训练期的交通模式泛化不够。这时候优先增加训练数据量或者降低隐藏层维数而不是继续堆层数。6.3 输入窗口长度和预测步长的取舍P取12还是24Q取3还是6对结果影响很大。我测试下来P12时模型已经能捕捉到模拟数据里的120时间片周期P太大会让样本数减少同时模型要学习更长的依赖关系但这里的时间卷积只有两层感受野有限反而不如短窗口效果好。实际项目中如果你用的是5分钟粒度数据想预测未来30分钟用P12一小时历史通常是个不错的起点。预测步长Q不宜一次性设太长。一步到位预测未来12个时间片误差会随着预测距离快速累积因为每一步的误差都会作为下一次的隐性输入。更稳的做法是先用P12预测Q6跑通整个流程后再把Q改成12对比误差增长曲线。训练时也可以把每个预测步的损失加权让近端步数权重更高。最后分享一个小技巧如果发现某个节点的预测误差总是明显高于其他节点不要急着调模型先画一下这个节点的流量曲线。很多路网检测器本身会有周期性缺失或突变点GCN再怎么改也救不了脏数据。先把异常时间点清洗掉比换什么模型都管用。我在做这个模拟项目时对这个体会特别深——有时候结果不好的原因不在架构而在你对数据的态度。GCN是一种工具它只能帮你把路网结构“翻译”成模型能理解的语言但路网里的真实规律还是要靠你在动手调参之前先把数据看透。