全连接时空图:统一建模多变量时序时空依赖
发布时间:2026/10/26 11:35:38 锦皓数字建站

多变量时间序列这块我啃了挺久踩过的坑基本都集中在同一个地方变量之间的关系和时间上的依赖到底该分开学还是一起学。分开学的方案占了绝大多数先拎出一张变量图把空间关系卷积一遍再拎出一条时间轴用注意力或者空洞卷积把时序依赖扫一遍最后拼在一起过一个融合层。这套流程跑得通指标也好看但我一直觉得哪里别扭。全连接时空图Fully-Connected Spatial-Temporal Graph这个思路第一次看到的时候就是冲着这个别扭去的——它不问先空间后时间还是先时间后空间直接把多变量时间序列摊成一张图让空间和时间在同一个节点集合里平起平坐。这篇内容适合两类人一类是已经用过多变量时序模型、想搞明白图结构还能怎么改的中级选手另一类是刚接手传感器阵列、交通流量、设备工况这类多通道预测任务还在纠结要不要上 GNN的工程同学。我会把这张图怎么建、参数怎么算、代码怎么写、坑在哪一条线捋下来中间穿插我自己实测过的配置和几次翻车的经过。1. 全连接时空图到底在解决什么问题1.1 分成两步走的老方案问题出在“中间层”传统做法里多变量时间序列被当成一个二维张量处理形状是[batch, 变量数 N, 时间步 T]。空间建模那一支通常先算一个 N×N 的邻接矩阵可能是基于传感器物理距离也可能是基于变量间相关系数然后套 GCN 或者 GAT在变量维度上做消息传递。时间建模那一支常见的是在 T 维度上堆 Transformer 编码层、TCN 或者空洞因果卷积让每个变量各自看自己的历史。两支各自输出一份表示再拼起来喂给预测头。问题在哪空间那一支在做卷积的时候时间维度是被压平的或者被当成特征通道的它压根不知道当前这个时间步的邻居信息来自哪一刻时间那一支在做注意力的时候每个变量是独立的它看不见同一时刻其他变量的状态。也就是说两个分支各自都有信息盲区而这个盲区不是靠加层数能补回来的。更麻烦的是融合这一步。两个分支的表示语义不一样空间分支输出的是这个变量和邻居比怎么样时间分支输出的是这个变量和自己过去比怎么样简单 concat 之后扔给 MLP模型要自己去学这两套语义怎么对齐。实践中你会发现融合层的参数量往往不小但带来的增益有限因为信息在进入融合层之前就已经被各自的分支洗过一遍了。1.2 换个节点定义一切都变了全连接时空图的核心动作其实特别朴素不再把节点定义为一个变量而是定义为某一时刻的某一个变量。原来 N 个节点现在变成 N×T 个节点。第 i 个变量在第 t 个时刻的观测值就是节点 (i, t)。这个定义一改整个问题的形态就变了。原来需要两张图分别描述空间和时间现在只需要一张图节点总数是 N×T邻接矩阵是 (N×T)×(N×T)。这张图是全连接的意思是任意两个节点之间都允许存在一条边——(i, t) 和 (j, t) 之间的边是空间边(i, t) 和 (i, t) 之间的边是时间边(i, t) 和 (j, t) 之间的边是时空交叉边。三类边共存于同一张邻接矩阵里。这么做的直接好处是一次图卷积节点 (i, t) 就能同时收到同一时刻其他变量的信息、同一变量其他时刻的信息以及既不同变量又不同时刻的信息。信息不需要在分支之间搬运也不需要融合层去对齐语义因为压根就没有分支了。代价也很明显而且这个代价是所有落地时最先撞上的墙邻接矩阵的规模是 (N·T)²。N170、T12 的时候是 2040×2040约 416 万个元素还能接受N500、T96 的时候是 48000×4800023 亿个元素纯 float32 就要 9.2 GB直接劝退。所以这套方法能不能用第一道门槛不是效果是规模。1.3 三条技术路线的横向对照我把常见的三类方案摆在一起比一下方便你判断自己该往哪条路走。这里的判断依据来自我实际跑过的几个项目和读代码时积累的直觉不是绝对结论选型时还是要拿自己的数据验一遍。维度分离式时空建模全连接时空图纯时序大模型通道混合节点定义N 个变量节点 T 个时间位置N×T 个时空节点无显式图结构空间依赖显式建模靠预定义或学习邻接矩阵显式建模与时间边共存靠通道混合层隐式学习时间依赖显式建模靠注意力或卷积显式建模同一张矩阵内显式建模序列建模为主时空交叉依赖基本无法直接表达原生支持隐式难以解释参数量级O(N²) O(T²)O((N·T)²)O(d²·L)可解释性空间边、时间边可分别可视化可切出三个子矩阵分别看弱适用规模大规模 N 或长 T中等规模N·T 控制在数千大规模通道数大也扛得住这张表里最值得注意的是时空交叉依赖那一行。很多实际场景里真正有用的信号恰恰是交叉的——比如某台设备在启动阶段的振动和另一台设备在启动后 3 秒的温度这两者之间的关联既不是纯空间也不是纯时间。分离式建模对这类关系的表达能力很弱全连接图则是天生支持。2. 图结构怎么建节点、边权重与三类依赖2.1 节点特征不能只有观测值把节点定义为 (i, t) 之后节点特征最直接的做法就是取该时刻该变量的观测值再拼上历史滑窗的统计量。但我实测下来光有观测值不够原因是节点之间失去了身份区分。你想节点 (3, 5) 和节点 (7, 5) 的特征向量如果恰好数值接近模型在没有额外信息的情况下很难分清哪个是 3 号变量、哪个是 7 号变量更难分清时间位置。所以在节点特征里必须显式注入两类位置信息变量身份嵌入和时间步嵌入。做法很常规就是两张可学习的 embedding 表一张大小是 N×d一张大小是 T×d按节点索引取出来和观测值映射后的特征相加或者拼接。这个操作跟 Transformer 里的位置编码是一个道理只不过这里是二维的。我这里犯过一个错值得说一下。早期版本我只加了时间步嵌入觉得变量身份靠邻接矩阵就能区分。结果模型在训练集上拟合得飞快验证集上一直原地踏步。后来把变量嵌入加上去验证集指标直接跳了一截。原因是邻接矩阵在训练初期接近随机模型分不清变量只能靠特征数值硬记泛化自然差。这个细节在不少复现笔记里被一笔带过但它是实打实影响收敛的。节点特征的完整构造大致是这样观测值经过一层线性映射到 d 维加上变量嵌入加上时间步嵌入如果窗口内有缺失值再拼一个掩码位。拼接完的维度控制在 64 到 128 之间太大会让后面的邻接矩阵乘法很难受。2.2 空间边的权重怎么来邻接矩阵是全连接的但全连接不代表权重都一样。如果所有权重初始化成相同值图卷积就退化成了对所有节点求平均那就白搭了。所以关键在初始化得给每条边一个有意义先验。空间边同一时刻 t变量 i 和变量 j 之间通常用两种先验。第一种是物理距离衰减适用于传感器有明确空间坐标的场景形式是exp(-d²/σ²)d 是两个传感器的距离σ 控制衰减尺度。σ 的取法有讲究一般取所有距离的中位数这样能保证邻接矩阵不会太稠也不会太稀。第二种是特征相似度用两个变量在训练集上的时序相关性或者余弦相似度好处是不依赖坐标坏处是相关性只反映线性关系遇到非线性耦合会漏。我的经验是两个都用加权相加。物理距离反映的是应不应该有关联特征相似度反映的是实际有没有关联两者互补。权重不用手工调太细各给 0.5 起步后面交给可学习矩阵去修正。2.3 时间边和时空交叉边时间边在同一变量内部构建节点 (i, t) 和 (i, t) 之间。注意这里不是简单地按时间距离衰减因为时序上的依赖往往有周期性。我一般的处理是基础权重用时间距离的高斯衰减再叠加一个周期性先验——如果数据有明显的日周期比如 96 个点一天就把|t - t| mod 周期也纳入衰减计算取两者中较大的那个权重。时空交叉边的初始化最麻烦因为它没有现成的物理先验。常见做法是先给一个很小的常数或者用空间权重和时间权重的乘积作为初值然后在训练中让它自己学。我倾向后者乘积分解的好处是保留了既空间相关又时间相关的先验方向不会一上来就是噪声。这里有个容易忽略的点时间距离的衰减尺度 σ_t 和空间距离的 σ_s 往往差好几个量级。空间距离可能是几百米到几公里时间距离可能是 1 到 12 个步长。如果直接把两者塞进同一个高斯核数值上会严重失衡。我的处理是分别归一化到 [0, 1] 之后再融合这一步不做的话训练初期梯度会很不稳。2.4 可学习矩阵与三者的融合方式前三类先验矩阵都是静态的真正让模型有能力修正的是可学习矩阵。做法是维护一个形状为 (N·T)×(N·T) 的参数矩阵初始化为零或者小的随机值训练中自由更新。最终邻接矩阵是四部分的加权和空间先验矩阵 A_s只在同一时刻的块对角内非零时间先验矩阵 A_t只在同一变量的块内非零时空交叉先验矩阵 A_st可学习矩阵 A_l权重可以是固定的标量也可以是可学习的标量。我倾向于用可学习的因为不同数据集上四部分的贡献差异很大——交通数据里空间先验的权重通常更高工业设备数据里时间先验往往占主导让模型自己学比手工调稳。融合完之后一定要做一次归一化用 softmax 或者对称归一化D^{-1/2} A D^{-1/2}。不做归一化的话节点度数差异会非常剧烈度数高的节点在卷积后数值爆炸度数低的节点几乎没信息。这一步是必须的我见过不止一份复现代码在这里偷懒然后靠梯度裁剪硬撑效果很差。3. 从零实现一次完整的动手过程3.1 数据切窗与归一化的细节假设原始数据形状是[总时长, N]。切窗用滑动窗口窗口长度 T预测长度 H步长一般取 1训练集或者 H验证测试集避免重叠泄漏。切完之后得到[样本数, N, T]转置成[样本数, T, N]再摊平成[样本数, T*N]作为图的节点特征输入。归一化这一步千万别用全局均值方差。多变量时间序列里不同变量的量纲差异可能极大比如温度是几十、压力是几千、流量是个位数。我一般做按变量的 z-score均值和方差只在训练集上统计。有两个坑一是测试集的归一化必须复用训练集的统计量不能重新算二是如果某个变量在训练集上方差接近零比如常开的开关状态除的时候会炸加个eps1e-8兜住。缺失值处理也要提前做掉。传感器阵列丢数据是常态简单的做法是前向填充加掩码位复杂一点的用插值。我的做法是前向填充后用掩码位标记把掩码位作为节点特征的额外一维让模型自己决定信不信这个值。实测下来比强行插值好因为插值会引入虚假的平滑信号。3.2 邻接矩阵初始化代码下面这段是我常用的模板把四部分拼起来逻辑比较直白import torch import torch.nn as nn class STGraphBuilder(nn.Module): def __init__(self, num_vars, num_steps, dist_spatial, dist_temporal, sigma_s1.0, sigma_t1.0, init_scale0.01): super().__init__() self.N, self.T num_vars, num_steps n_node num_vars * num_steps # 空间先验只在同一时刻的变量之间 a_s torch.exp(-(dist_spatial ** 2) / (sigma_s ** 2)) A_s torch.zeros(n_node, n_node) for t in range(num_steps): idx torch.arange(t * num_vars, (t 1) * num_vars) A_s[idx.unsqueeze(1), idx.unsqueeze(0)] a_s # 时间先验只在同一变量的不同时刻之间 a_t torch.exp(-(dist_temporal ** 2) / (sigma_t ** 2)) A_t torch.zeros(n_node, n_node) for i in range(num_vars): idx torch.arange(i, n_node, num_vars) A_t[idx.unsqueeze(1), idx.unsqueeze(0)] a_t # 时空交叉先验用乘积做初值 A_st torch.sqrt(A_s * A_t 1e-8) self.register_buffer(A_s, A_s) self.register_buffer(A_t, A_t) self.register_buffer(A_st, A_st) # 可学习修正项 self.A_learn nn.Parameter(torch.randn(n_node, n_node) * init_scale) # 融合权重初始给先验主导 self.w nn.Parameter(torch.tensor([0.4, 0.4, 0.1, 0.1])) def forward(self): w torch.softmax(self.w, dim0) A (w[0] * self.A_s w[1] * self.A_t w[2] * self.A_st w[3] * torch.sigmoid(self.A_learn)) # 对称归一化 deg A.sum(-1).clamp(min1e-6) d_inv_sqrt torch.pow(deg, -0.5) A_norm d_inv_sqrt.unsqueeze(-1) * A * d_inv_sqrt.unsqueeze(0) return A_norm几个实现上的说明。A_learn我用了 sigmoid 而不是直接取原始参数原因是自由参数在训练中容易飘到负值做了变换之后保证在 [0,1] 区间物理意义清晰——它更像是在先验基础上做边的保留概率。融合权重用了 softmax保证四项加起来为 1避免整体幅度漂移。那个双重循环建块对角结构的写法有点慢实际项目中我会用repeat_interleave或者torch.block_diag加速。变量数几百的时候差异不明显上千的时候能省不少初始化时间。3.3 图卷积层的写法与参数规模估算图卷积本身很标准一层就是A_norm H W。问题是稠密矩阵乘法的计算量。我算一笔账N170、T12节点数 2040特征维 d64。一次图卷积的乘加次数约是2040 × 2040 × 64 ≈ 2.66 亿FLOPs 翻倍算 5.3 亿。两层就是 10 亿出头batch size 取 16 的话每步前向 170 亿 FLOPs。这个量级用一张中端显卡完全跑得动但训练时间会比同规模的分离式模型慢三到四倍。所以我一般只堆两层图卷积不会更多。堆到四层以上你会发现收益急剧递减还会碰到过平滑问题——所有节点的表示趋同模型失去分辨能力。这个现象在节点数多、边长程连接多的图里特别明显。缓解手段是加残差连接和 LayerNorm我通常每层都加形如H_out LayerNorm(H_in GCN(H_in))。参数规模也要提前算清楚这决定了你的部署方案。下面这张表是我按不同规模实测或估算的结果选型时先对着表看一眼规模场景变量数 N窗口 T节点数 N·T稠密邻接参数float32 显存小型传感器组20122405.76 万0.23 MB中型交通网络170122040416 万16.6 MB较大设备阵列3002472005184 万207 MB大型电力网络500964800023 亿9.2 GB机房监控全量200060120000144 亿57.6 GB分界线大概在稠密邻接参数 5000 万这个量级。超过之后光存这个矩阵就吃掉大量显存再加上反向传播要存梯度和中间激活基本就没法训了。所以 N·T 控制在 7000 以内是比较舒服的区间超过就得动结构。3.4 规模超限时的三条改造路径第一条是低秩分解。不直接维护 (N·T)² 的矩阵而是分解成A ≈ U V^T其中 U、V 的形状都是(N·T) × rr 取 16 到 64。参数量从(N·T)²降到2·N·T·rN500、T96、r32 的时候从 23 亿降到 307 万差三个数量级。代价是表达能力受限只能表达低秩结构的关系。我实测在交通数据上低秩版本和稠密版本的效果差距很小因为真实的空间关联确实近似低秩——几百个传感器之间的关系本质上由几十条主干道决定。第二条是时间局部化。同一变量只和前后 k 个时间步连边不做全时间跨度连接。k 取 3 到 5邻接矩阵的稀疏度大幅提升可以换成稀疏矩阵乘法。这条路径适合长窗口场景代价是丢失了远距离时间依赖需要靠多层堆叠来间接传递。第三条是分块加跨块注意力。把节点按变量分组每组内部用稠密图组之间用少量注意力头做粗粒度交互。这个思路介于分离式建模和全连接图之间工程上最好实现效果也最稳。4. 踩坑记录几类高频问题与排查路径4.1 邻接矩阵塌缩成均匀分布这是最隐蔽也最常见的问题。训练一段时间后你把邻接矩阵打印出来看发现几乎所有位置的值都差不多图卷积实际上变成了对所有节点求平均。根本原因是可学习矩阵的梯度信号太弱。初始化的时候 A_learn 接近零sigmoid 之后是 0.5也就是说所有边都有一个 0.5 的基础权重模型很难跳出这个平坦区域。我试过几个改法一是把初值调得更负比如 -2让 sigmoid 之后接近 0.12边与边之间的相对差异更明显二是在损失里加一项熵正则惩罚邻接矩阵分布过于均匀三是把可学习部分的权重初始值调小让先验矩阵主导前若干个 epoch等特征学得差不多了再放开。第三种效果最好。我的做法是给融合权重加一个 warmup 策略前 20% 的 epoch 里把可学习项的权重压到 0.02之后线性升到正常值。这样模型前期靠先验打好底子后期再用自由参数微调。检查方法每训练 5 个 epoch 打印一次邻接矩阵的标准差如果连续下降并且逼近零基本就是塌缩了。4.2 过平滑与梯度消失节点数一多堆两层以上图卷积就会出现过平滑。表现为训练损失正常下降但验证集指标卡住不动把中间层输出拿出来看节点之间的余弦相似度普遍在 0.95 以上模型已经分不清节点了。缓解手段我按有效性排序残差连接最有效每层都加LayerNorm 次之再就是控制层数不超过两层。另外还有一个不太被提及的招——在卷积前对节点特征做一次随机丢弃比例 0.1 左右人为制造节点间的差异能显著延缓同质化。这个方法我是在做图像任务时想到迁移过来的实测在小规模数据上提升明显。梯度问题通常出现在邻接矩阵规模大的时候。对称归一化如果度数计算有偏差会出现除零或者数值溢出。我习惯在clamp的时候把下界设成1e-6而不是1e-8因为 float32 下太小的数参与除法容易产生 inf。4.3 预测头的设计别偷懒图卷积输出的是 N×T 个节点的表示但预测目标通常是未来 H 步的 N 个值。从 N×T 个节点到 N 个输出中间怎么聚合这一步的设计对最终指标影响很大比图结构本身的影响还大。最省事的做法是只取最后几个时间步的节点表示拼起来过 MLP。这个做法的问题是浪费了前面时间步的表示。好一点的做法是注意力池化学一组查询向量对所有节点做加权求和。我的做法是按变量分组——对每个变量 i把它在 T 个时间步上的节点表示聚合成一个向量得到 N 个变量表示再分别过预测头输出 H 步。这样做的理由是保持了变量维度的语义对齐预测头可以对每个变量用共享参数参数量小很多泛化也稳。我对比过全局池化和分组池化后者在同参数量下 MAE 能低 3% 到 5%尤其在变量数多的时候差距更明显。4.4 消融实验怎么设计才有说服力这套结构里可拆的部件不少空间先验、时间先验、交叉先验、可学习矩阵、变量嵌入、分组池化。如果只做一个完整模型 vs 去掉某部件的对比说服力其实有限因为你不知道去掉的那个部件是不是被别的部件补偿了。我的做法是做三组消融。第一组是单项移除看每个部件的边际贡献第二组是替换实验把全连接图换成块对角图只保留空间边、换成带状图只保留时间边这三者对比能直接说明交叉边到底有没有用第三组是规模扫描把窗口长度 T 从 6 扫到 48看增益随规模的变化趋势。第三组最有信息量——如果增益在 T 增大时迅速衰减说明你的模型主要吃的是短期信息长窗口的收益被过平滑吃掉了。跑消融的时候随机种子至少取 3 个报均值和标准差。多变量时序任务的方差很大单次结果差个 2% 完全可能是噪声我吃过这个亏一度以为某个改动有效复跑三次发现是运气。5. 适用边界与落地场景的改造要点5.1 什么情况下不该上这套结构先说清楚不适合的场景省得白费功夫。如果变量之间基本独立比如同一批设备的同型号传感器各自工作互不影响那空间边学出来就是噪声全连接图退化成了一堆无用参数不如老老实实用时序模型。判断方法很简单算一下变量间相关矩阵如果最大相关系数普遍低于 0.2这条路就别走了。如果序列特别长T 超过 200节点数会爆炸除非上低秩或者局部化否则算力吃不消。如果数据量很小样本数少于几千(N·T)² 级别的参数量必然过拟合压缩到几十个变量以内才勉强能用。反过来这套结构最吃香的场景有三个特征同时具备变量数中等几十到几百、窗口长度适中十几到几十、变量之间存在明确的物理或功能耦合。工业设备的传感器阵列、区域交通流量、多导生理信号基本都符合。5.2 三个典型场景的改造要点设备状态监测。传感器之间往往有明确的物理拓扑空间先验用物理距离最合适。但要注意工况变化——设备在启动、稳态、停机三个阶段的变量耦合关系完全不同。我的做法是按工况分段建图或者把工况标签作为节点特征的一部分让模型在不同区间学不同的边权重。实测分段建图的效果更好代价是每个工况的样本变少需要做参数共享。交通流量预测。检测点之间的距离矩阵很好拿空间先验质量高。这类数据的另一个特点是强周期性时间边一定要引入周期先验否则模型学不到早晚高峰的规律。窗口建议覆盖完整周期比如 5 分钟粒度取 12 个点1 小时或者 288 个点1 天。后者节点数太大我一般用前者配合日周期嵌入。多通道生理信号。导联之间的相关性很强但个体差异巨大同一套邻接矩阵在不同受试者之间迁移效果差。我的做法是把邻接矩阵拆成群体共享部分加个体偏置部分个体偏置用小秩矩阵表示这样参数量可控又能适配个人差异。这个改法在跨受试者评估里提升明显代价是要为每个受试者单独存一个小矩阵。5.3 训练配置的一些实测参数最后记一下我常用的配置供参考。优化器用 AdamW学习率 1e-3 配 5% 的线性 warmup权重衰减 1e-4。batch size 取 32 到 64太小的话邻接矩阵的梯度噪声大训练不稳。梯度裁剪阈值设 1.0图模型里梯度爆炸比梯度消失更常见。早停的耐心值设 15 个 epoch监控指标用验证集 MAE。学习率调度我试过 Cosine 和 ReduceLROnPlateau后者在时序任务上更稳因为不同阶段的收敛速度差异大。另外邻接矩阵的参数建议用更大的学习率比如主网络的 3 倍因为它需要更快地从初始先验位置移动出来否则会被主网络的收敛速度拖着走。训练时间上2040 个节点的配置单个 epoch 在中等规模数据几万样本上大概 40 到 60 秒跑 100 个 epoch 大约一个半小时。调试阶段我一般先把 T 缩到 6、变量数减半用 10% 的数据快速验证代码没写错再放大跑全量。这个习惯帮我省了大量时间——图模型的 bug 在大规模数据上暴露得很慢往往是跑了两小时才发现维度对不上。踩过几次坑之后我最大的体会是这套结构的价值不在于图神经网络这四个字而在于它逼着你把时空关系想清楚。你在构建设计邻接矩阵的时候必须明确回答这两个节点为什么该有边这个思考过程本身带来的收益有时候比模型最后涨的那几个点更值钱。至于具体用稠密还是低秩、用几层卷积、池化怎么做都可以在自己的数据上慢慢试出来不用迷信某一份配置。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。