简介:针对城市交通流量预测中时空特征提取难的问题,一份基于小波变换与图注意力网络(WT-GAT)的MATLAB项目实例文档面向智能交通研究人员、工程师及高校师生分享,适合具备一定MATLAB和机器学习基础、希望掌握深度学习与信号处理融合技术的读者。文档以完整项目为主线,涵盖数据预处理、邻接矩阵构建、特征归一化、模型训练、性能评估与可视化等核心环节,并设计GUI界面便于交互调试,可深入理解小波变换多尺度时序特征提取与GAT空间依赖建模的协同机制。资源包共1个docx文档,大小65KB,内部按项目背景、挑战与解决方案、模型架构等目录有序组织,提供代码详解与部署思路,便于快速复现实验并开展科研或工程实践。内容讲解细致,尤其适合需要理解小波分解参数设置、邻接矩阵构建逻辑与GAT注意力权重计算等细节的读者。目前已有72人学习,值得时空数据建模与智能交通方向的开发者参考。
1. 交通流量预测为什么选小波变换加图注意力网络这套组合
交通流量预测这件事,最难的不是把LSTM调深,而是同时吃透两条轴:时间轴上的非平稳趋势,空间轴上的路网依赖。MATLAB 实现里比较完整的一套组合是 WT-GAT:先用小波变换(WT)把传感器流量序列拆成多分辨率子带,再交给图注意力网络(GAT)在路网拓扑上学习动态邻居权重,最后装进带 GUI 的预测工具里,从加载数据到出图一条线走通。这套方案适合手头有路口流量传感器矩阵、想从单一时间序列模型切换到时空模型的工程人员。下面按原理、代码骨架、GUI 设计和验证顺序展开,尽量让照着手敲就能跑通。
2. WT-GAT 的原理与数据流:从序列分解到路网注意力
2.1 小波变换:把流量序列拆成趋势、细节和噪声
交通流量的原始序列是典型的非平稳信号:24 小时周期性叠加早晚高峰突变,再加上事故、天气造成的局部尖峰。直接用原始序列训练时,模型要同时拟合低频趋势和高频毛刺,容量被分散,预测误差集中在高峰切换的十几个样本点。小波变换通过母小波的伸缩和平移把信号分解到不同尺度,能把趋势项、周期项和随机项分开处理。
离散小波变换的做法是每次把信号通过一对高低通滤波器,得到低频近似系数和高频细节系数,再对低频系数继续分解。level 级分解后得到 1 个近似子带和 level 个细节子带。MATLAB 里wavedec负责分解,wrcoef可以把某级系数重建回原采样长度,便于和原始时间轴对齐。对 15 分钟粒度的日数据,3 级分解是常用选择:近似分量对应当日趋势,中间两层细节对应半小时到两小时尺度的拥塞波动,第一层细节对应随机噪声。
% 单节点流量序列的 3 级小波分解 x = flow(1, 1:96); % 某传感器一天的数据(15min粒度) [c, l] = wavedec(x, 3, 'db4'); % c 为拼接系数, l 为各层长度 a3 = wrcoef('a', c, l, 'db4', 3); % 近似分量, 长度回到 96 d1 = wrcoef('d', c, l, 'db4', 1); % 第1级细节, 对应高频噪声 d2 = wrcoef('d', c, l, 'db4', 2); d3 = wrcoef('d', c, l, 'db4', 3);参数说明:flow是 N×T 的传感器流量矩阵,这里取第 1 个节点的 96 个点;wavedec的第三个参数选db4,它是 Daubechies 4 阶小波,时域支撑短、平滑性够,适合交通流这种没有剧烈尖峰的信号。level 设为 3 兼顾趋势保留和特征维度,再往上会把日趋势也拆碎。校验分解正确性最直接的办法是把d1到d3和a3逐点相加,结果应等于原序列。
2.2 图注意力网络:节点权重随路况动态变化
路网上的流量传感器天然构成一张图:节点是传感器,边是上下游连接或者基于距离的空间邻接。普通 GCN 的做法是用归一化邻接矩阵做固定权重的邻居聚合,问题在于权重一经归一化就固定了,早高峰和事故场景下真实的空间依赖差别很大。图注意力网络把邻居聚合权重换成注意力系数,权重随输入特征动态变化。
注意力系数的做法是对节点 i 的每个邻居 j 计算相关性分数,再做行 softmax。分数公式为LeakyReLU(a^T [W h_i || W h_j]),其中 W 是共享的特征变换矩阵,a 是注意力向量,|| 表示拼接。节点 i 的更新输出是邻域特征的加权和;多头注意力把多组结果拼接或平均,抑制单头注意力在局部数据上的偏置。相比 GCN 的固定权重,GAT 在拥堵消散、信号灯失衡这类动态场景里能把注意力及时转移到真正影响下游流量的节点上。
实操中邻接矩阵不参与反向传播,它只决定哪些节点对参与注意力计算。这个设计有个直接好处:即使邻接矩阵是手工按路网拓扑画的,模型也不会被不准确的边权重误导,因为边关系变成候选集,具体用多少由注意力决定。
2.3 WT-GAT 整体数据流与张量形状
WT-GAT 的经典接法是逐节点做小波分解,把各子带沿通道维堆叠,再展平成单个特征向量,作为该节点的输入特征进入 GAT 层。这一步等价于先用小波做多尺度特征提取,再做空间注意力聚合,最后通过全连接直接输出预测窗口。展开后不再依赖 GRU/LSTM 也能拿到时间依赖,因为小波分解已经按频段建模了时间结构,GAT 只负责在节点间交换信息,各模块职责明确,梯度路径短。
表 2-1 给出一个 24 节点传感器网络的数据流,输入是过去 96 个时间步,输出未来 6 步。
| 阶段 | 输入形状 | 输出形状 | 说明 |
|---|---|---|---|
| 原始输入 | 24×96 | 24×96 | 24 个节点、96 步历史流量 |
| 小波分解 | 24×96 | 24×96×4 | A3、D3、D2、D1 各 96 点沿通道拼接 |
| 特征展平 | 24×96×4 | 24×384 | 把 (96×4) 拉成单向量 |
| GAT 层 | 24×384 | 24×128 | 多头注意力聚合节点信息 |
| 输出层 | 24×128 | 24×6 | 全连接输出未来 6 步 |
窗口选 96 是因为 15 分钟粒度下一天正好 96 点,模型至少能看到一个完整日周期。如果数据粒度是 5 分钟,窗口要拉到 288,特征维度会明显变大,这时可以只保留近似分量和后两级细节,把通道压到 3,再用 1×1 卷积降维,避免全连接参数爆炸。
3. MATLAB 实现 WT-GAT:小波分解、邻接矩阵与训练循环
3.1 数据加载、归一化与小波分解
读入数据后,先把流量矩阵按行归一化。归一化统计量只能从训练段计算,验证段和测试段复用同一组 min 和 max,不能重新计算,否则测试集信息会混进模型。
function [flow, minV, maxV] = load_and_scale(filepath) d = load(filepath); raw = d.flow; % 期望格式: N×T, N 为传感器数, T 为时间长度 minV = min(raw, [], 2); maxV = max(raw, [], 2); flow = (raw - minV) ./ (maxV - minV + eps); end参数说明:按行做 min-max 归一化,每行一个传感器节点,避免全局 min/max 被数值大的节点主导;eps防止零流量路段出现除零。读入的raw如果有缺失值,建议先用fillmissing(raw, 'linear', 2)沿时间维线性插值,再把超过 3σ 的异常点替换成前后均值。
小波分解的批量封装如下,输出保持和输入相同的时间长度。
function features = wt_features(flow, level, wavelet) % flow: N×T, 返回 N×T×(level+1) 的特征张量 [N, T] = size(flow); features = zeros(N, T, level + 1); for i = 1:N [c, l] = wavedec(flow(i, :), level, wavelet); features(i, :, 1) = wrcoef('a', c, l, wavelet, level); for k = 1:level features(i, :, k + 1) = wrcoef('d', c, l, wavelet, k); end end end一个容易混淆的点:wrcoef('a', ...)只重建近似分量,waverec重建整个信号,两者用途不同。上面代码把各子带全部重建回原长度,时间轴对齐为 T,特征张量喂给 GAT 前再 reshape 成 N×(T×C)。如果发现 D1 通道全是噪声、对指标没有贡献,可以只保留 A3、D3、D2 三个通道,把通道数从 4 降到 3。
3.2 邻接矩阵构造与 GAT 层实现
邻接矩阵优先使用路网拓扑关系:两条路物理相连且有上下游关系就建边。没有拓扑信息时,用坐标按 k 近邻构造,典型写法如下。
function A = build_adjacency(coords, k) % coords: N×2 平面坐标(投影后使用), k 为选取的邻居数 N = size(coords, 1); dist = zeros(N, N); for i = 1:N for j = 1:N dist(i, j) = norm(coords(i, :) - coords(j, :)); end end A = false(N, N); for i = 1:N [~, idx] = sort(dist(i, :)); % idx(1) 是自己 A(i, idx(2:k+1)) = true; % 取最近 k 个邻居 end A = A | A'; % 对称化 A(1:N+1:end) = false; % 去掉自环 end距离阈值写起来简单,但对平行且靠近的道路会产生错误边;按 k 近邻构造能控制每个节点的度数,对称化保证无向图,GAT 的聚合范围不会出现单边连接。实际项目中一般把 k 设在 3~5,对应十字路口上下游加相邻侧路。
GAT 层用最直接的标量注意力实现,方便阅读和调试。
function out = gat_layer(X, A, W, a, alpha) % X: N×d 节点特征, A: N×N 逻辑邻接矩阵 % W: d×d_out, a: 2*d_out×1, alpha: LeakyReLU 负斜率 H = X * W; % N×d_out N = size(H, 1); scores = -inf(N, N); for i = 1:N for j = find(A(i, :)) scores(i, j) = leaky_relu(a' * [H(i, :)'; H(j, :)'], alpha); end end e = exp(scores - max(scores, [], 2)); % 减最大值防溢出 att = e ./ sum(e, 2); out = att * H; % 加权聚合, 输出 N×d_out end function y = leaky_relu(x, alpha) y = max(x, alpha * x); end这个实现里scores中非邻居位置保留-inf,softmax 后概率为 0,注意力只作用在 A(i,j)=1 的邻居上。节点数超过 200 后,find(A(i, :))每次都扫描整行,建议改成 adjacency list 缓存邻居索引,训练时间可以从小时级降到分钟级。
提示:GAT 层的前向里不要放
extractdata,否则自动微分被截断,loss 不下降且梯度全为 0。
3.3 训练循环、损失计算与 dlarray 前向
训练要用dlarray包住参数才能走自动微分。3.2 的gat_layer是纯数值版,用于调试形状;训练前向需要一个接受dlarray的版本gatLayerDl,两者结构一致,只差输入类型。
function H = gatLayerDl(X, A, W, a, alpha) % X: N×D dlarray, A: N×N logical, W: D×H1, a: 2H1×1 H1 = X * W; % N×H1 N = size(H1, 1); scores = -inf(N, N); for i = 1:N for j = find(A(i, :)) scores(i, j) = leaky_relu(a' * [H1(i, :), H1(j, :)]', alpha); end end scores = dlarray(scores); att = softmax(scores, 2); % R2022b 之后 dlarray 支持 softmax H = att * H1; end配套的自定义训练循环如下。dlfeval计算损失和梯度,adamupdate完成参数更新。
function [net, lossHist] = train_wt_gat(features, y, A, params) % features: N×T×C 小波子带, y: N×H 目标, A: N×N 邻接矩阵 [N, T, C] = size(features); X = single(reshape(features, N, T * C)); % N×D, D = T*C D = size(X, 2); net.W = dlarray(randn(D, params.hidden) * 0.01); net.a = dlarray(randn(2 * params.hidden, 1) * 0.01); net.V = dlarray(randn(params.hidden, params.horizon) * 0.01); lossHist = zeros(params.epochs, 1); avgG = []; avgSq = []; Xdl = dlarray(X); for ep = 1:params.epochs [loss, grads] = dlfeval(@modelLoss, net, Xdl, A, single(y)); [net, avgG, avgSq] = adamupdate(net, grads, avgG, avgSq, ep, params.lr); lossHist(ep) = extractdata(loss); end end function [loss, grads] = modelLoss(net, X, A, target) H = gatLayerDl(X, A, net.W, net.a, 0.2); yPred = H * net.V; % N×H loss = mse(yPred, dlarray(target)); grads = dlgradient(loss, net); % 对结构体字段分别求梯度 end参数说明:hidden是 GAT 隐层维度,horizon是预测步长;初始化用 0.01 量级的随机值,防止注意力分数一开始就饱和。这个自定义循环比trainNetwork灵活,GAT 自定义层也能注册进layerGraph,但注册过程对经常改网络结构的项目偏繁琐,自定义循环改起来更快。
3.4 参数速查表与调参顺序
表 3-1 列出这套实现里最常调的参数,按影响程度从上到下排列。
| 参数 | 推荐值 | 调整方向 |
|---|---|---|
| 小波层数 level | 3 | 数据毛刺多时加到 4,注意 D1 通道可能全噪声 |
| 小波基 | db4 / sym4 | sym4 对尖峰更友好,但边界效应更长 |
| 历史窗口 T | 96 | 按数据粒度换算成至少一个日周期 |
| GAT 隐层维度 | 32~64 | 传感器少用 32,节点多于 50 用 64 |
| 注意力头数 | 2~4 | 单头在拥堵场景容易过拟合 |
| dropout | 0.2 | 训练损失低、验证损失高时优先调大 |
| 学习率 | 1e-3,预热后 5e-4 | 配合 Adam,150 轮后看 loss 曲线 |
调参顺序建议固定为:先定小波层数和基函数,再定窗口 T,最后调 GAT 隐层和学习率。先调学习率成功率不高,因为 loss 波动多半来自前两个设置的信号错配。参数定完还要检查训练/验证切分方式,这个放到最后一章专门说。
4. 用 MATLAB App Designer 搭建交通流量预测 GUI 工具
工程上只写脚本不好交付,现场人员打开 MATLAB 就要能改参数出图。App Designer 比传统 GUIDE 更适合这套流程:回调函数是标准方法,控件属性在属性面板直接配,生成的 .mlapp 还能用 MATLAB Compiler 打包。下面按布局、回调和排错三节走。
4.1 界面布局:参数面板、按钮和坐标区
界面分三块:左侧放参数编辑框,中间放两个坐标区(原始流量、预测对比),底部放三个按钮(加载数据、训练模型、预测出图)。控件和用途如下表。
| 控件类型 | 名称 | 用途 |
|---|---|---|
| Button | LoadButton | 选择 .mat 数据文件 |
| EditField | LevelField | 小波层数,默认 3 |
| EditField | LrField | 学习率,默认 0.001 |
| UIAxes | RawAxes | 展示原始数据 |
| UIAxes | PredAxes | 展示预测对比 |
| Button | TrainButton | 训练模型 |
| Button | PredictButton | 预测出图 |
代码生成的骨架如下,省略自动生成的位置代码,只保留核心属性和私有数据区。
classdef TrafficGui < matlab.apps.AppBase properties (Access = public) UIFigure matlab.ui.Figure LoadButton matlab.ui.control.Button TrainButton matlab.ui.control.Button PredictButton matlab.ui.control.Button end properties (Access = private) FlowData double % 加载的流量矩阵 Net % 训练好的网络参数 Graph adjacency % 邻接矩阵 Params struct % 参数汇总 end methods (Access = private) % 训练流程封装 function ok = runTraining(app) % 训练代码在这里调用 end end end这里把 FlowData、Net 等作为 App 的私有属性保存,回调之间通过app.xxx传递数据,比 guidata 的存取方式直观。界面控件默认单位是像素,部署到不同分辨率屏幕前打开 UIFigure 的 Resize 策略,否则按钮位置会漂移。
4.2 回调函数串联数据加载、训练和预测
三个按钮的回调对应三件事。加载数据用uigetfile选择文件,训练按钮从编辑框读参数并调用train_wt_gat,预测按钮反归一化后画对比曲线。
function LoadButtonPushed(app, ~) [f, p] = uigetfile('*.mat', '选择流量数据'); if isequal(f, 0), return; end data = load(fullfile(p, f)); app.FlowData = data.flow; plot(app.RawAxes, 1:size(data.flow, 2), data.flow(1, :), 'LineWidth', 1); xlabel(app.RawAxes, '时间步'); ylabel(app.RawAxes, '流量'); end function TrainButtonPushed(app, ~) if isempty(app.FlowData) uialert(app.UIFigure, '请先加载数据', '提示'); return; end app.Params.level = str2double(app.LevelField.Value); app.Params.lr = str2double(app.LrField.Value); app.Params.hidden = 32; app.Graph = build_adjacency(app.Coords, 4); features = wt_features(app.FlowData, app.Params.level, 'db4'); [app.Net, lossHist] = train_wt_gat(features, app.Target, app.Graph, app.Params); plot(app.RawAxes, 1:numel(lossHist), lossHist); end function PredictButtonPushed(app, ~) if isempty(app.Net) uialert(app.UIFigure, '请先训练模型', '提示'); return; end [yPred, yTrue] = predict_wt_gat(app.Net, app.FlowData, app.Graph, app.Params); plot(app.PredAxes, 1:numel(yTrue), yTrue, 'b', ... 1:numel(yPred), yPred, 'r--', 'LineWidth', 1.5); legend(app.PredAxes, {'真实值', '预测值'}); end回调里第一个参数是 app 本身,第二个是事件数据,写~表示忽略。训练时间较长时界面会卡住,解决办法是用parfeval把训练任务挂到后台线程,回调里先把按钮文字改成"训练中...",训练结束再恢复。predict_wt_gat里要用训练阶段的 minV/maxV 做反归一化,否则画出来的预测曲线量纲不对。
4.3 运行报错排查表
GUI 跑起来之后,常见报错集中在下表。
| 报错特征 | 原因 | 处理办法 |
|---|---|---|
| load 之后坐标区不显示曲线 | flow 变量名不对 | 查看 .mat 实际字段名,改用data.(fn)取值 |
| 训练报错 dimension mismatch | 小波重建后的特征维度与 GAT 输入不一致 | 检查 features 是否为 N×T×C,reshape 时按 T*C 展平 |
| 报错找不到可执行文件 | 打包部署缺少 GPU 库 | MATLAB Compiler 打包时勾选对应的工具箱库 |
| 界面卡死无法点击 | 训练循环占用 UI 线程 | 用 parfeval 或 timer 异步执行训练 |
| 预测图全是一条直线 | 忘记反归一化或维度不一致 | predict 函数末尾加 denormalize,打印 size 核对 |
排查 GUI 问题的小技巧:在回调里用disp打印关键变量的 size,比一步步设断点快。App Designer 的断点调试和普通脚本一致,但uialert会阻塞后续代码,调试时先注释掉。
5. WT-GAT 落地验证与高频踩坑点
5.1 消融实验与快速验证脚本
验证不是看 loss 曲线多漂亮,而是看三个对比实验:完整 WT-GAT、去掉小波变换只保留 GAT、去掉 GAT 只用小波加全连接。三者输入输出和训练轮数保持一致,比较 RMSE 和 SMAPE。这个消融能一次性回答"WT 有没有用、GAT 有没有用"。
function res = quick_validate(net, X, A, y) yP = predictGat(net, X, A); diff = yP - y; res.rmse = sqrt(mean(diff.^2, 'all')); denom = abs(yP) + abs(y); smape = abs(diff) ./ denom; smape(denom == 0) = 0; res.smape = 200 * mean(smape, 'all'); endSMAPE 公式里流量为零时分母为 0,计算时置为 0。多步预测建议用直接多输出的方式,一次输出未来 6 步,而不是递归式地把预测值放回输入,后者误差会随时间步累积,第 4 步以后基本失去参考价值。
5.2 三个高频踩坑点
第一个坑是小波分解跨训练/测试集:对整段序列先wavedec再切数据,低频分量会把未来信息平滑进训练集,验证指标虚高。正确做法是先按时间切段,再在训练段内部分窗分解。
第二个坑是邻接矩阵边的范围。按距离建边容易把平行道路误连,预测图里会出现一条路的流量变化影响旁边路的情况。拓扑信息可靠时优先用真实路网关系,不可靠时把 k 从 4 减到 3,并观察注意力矩阵里是否出现持久的高权重跨道路连接。
第三个坑是零流量与节假日样本。夜间零流量路段会让 min-max 归一化后的分布严重偏斜,MAPE 在这些点上爆炸。处理方式是用 SMAPE 替代 MAPE,并在损失函数里对低流量样本做 mask,让模型把容量集中在早晚高峰的预测上。边界效应明显的短序列,训练前把dwtmode('per')再跑一遍,RMSE 往往能掉两三个点。
本文还有配套的精品资源,点击获取