☰
全连接时空图:多变量时序跨变量跨时间耦合建模
2026/10/26 11:35:28 网站建设 项目流程

做设备多传感器监测的同学大概都遇到过这种场面:某台机组的两个测点在绝大多数时间段里相关性都很低,真正能解释一次凌晨告警的现象,是“一号测点在三点零七分开始漂移,二号测点在三点十分跟着漂移”。这是一个横跨两个变量、跨越三个时间步的联动关系。当时用的模型是把变量之间的关系画成一张图、把时间上的前后依赖交给一维卷积去处理,两条线各走各的,这种“跨变量 + 跨时间”的耦合压根没有地方表达。

标题里这篇工作针对的就是这个缺口:把每个时刻的每个变量都当成图上的一个节点,节点之间全部允许连边,空间关系和时间关系被塞进同一张全连接时空图(Fully-Connected Spatial-Temporal Graph)里,让模型自己学出“谁在什么时候跟谁联动”。同样一份数据,图上学出来的高权重边正好落在前面说的那条链路上。

这套思路适合三类人:一是做多变量时间序列(MTS)分类、预测、异常检测,发现现有模型总是漏掉滞后耦合的;二是手上有一堆传感器、通道之间物理关系说不清楚、没法手工画图的;三是想搞清楚图神经网络在时序场景里到底该怎么建模、复杂度该怎么控制的。下面我从动机、图构造、复杂度、代码、排错、可解释性六个方面把这套东西拆开讲,能抄的部分直接给代码。

1. 一张图装下所有变量和时间步:这个建模思路到底在解决什么

先把问题摆清楚。多变量时间序列可以写成 X ∈ R^(T×N),T 是时间步数,N 是变量(通道、传感器、指标)数。传统做法几乎都是“两条腿走路”:空间维度上,用相关系数、DTW 距离或者领域先验,在 N 个变量之间构造一张邻接矩阵,跑图卷积;时间维度上,用 TCN、LSTM、Transformer 去建模前后依赖。两条腿各跑各的,最后拼接或者相加。

这个范式能work,是因为它做了两个很强的假设。假设一:变量之间的关系不随时间变化。邻接矩阵 A 是一个静态的 N×N 矩阵,训练完就固定了。但现实里工况一换,相关性就变了——设备启动阶段两个传感器高度相关,稳态阶段可能完全不相关。假设二:时间和空间可以解耦处理。这个假设更隐蔽,也更致命。它意味着“变量 A 在 t 时刻的变化影响变量 B 在 t+k 时刻”这类关系,只能靠堆叠层数去隐式逼近,模型没有任何显式的表达位置。

1.1 分开建空间图和时间图,丢掉的是“谁在什么时候跟谁联动”

我举个具体例子说明丢的是什么。假设有三路信号:电机电流、轴承温度、冷却液流量。故障发生时的典型序列是:电流先抖动(t=0),两秒后温度开始爬升(t=2),再三秒后流量异常(t=5)。这三件事在时间上是错开的,在变量上是有先后顺序的。

在“空间图 + TCN”的结构里,模型能学到的是:在同一个时间窗内,电流和温度相关;以及温度这条序列自身有上升趋势。但它学不到“电流在 t=0 的抖动,是温度在 t=2 爬升的原因”这种带方向的、跨变量跨时间的关联。要想让模型隐式捕捉到,你得堆足够多的层,让信息先沿时间轴传两格、再沿变量轴传一格,中间还得保证每一层的聚合不把信号糊掉。实际上大部分模型堆到五六层就开始过平滑了。

全连接时空图的做法是把这件事显式化:节点是 (变量 n, 时间 t) 这个二元组,边 (n1, t1) → (n2, t2) 表示“变量 n1 在 t1 时刻的状态,对变量 n2 在 t2 时刻的状态有影响”。这里面包含了四类边,我在第 2 节会展开。关键是,“电流 t=0 → 温度 t=2”这条边在图里是存在的,模型只需要学会给它一个高权重就行,不需要靠层数去拼凑。

1.2 全连接时空图的节点定义:从“变量是节点”到“每个时刻的每个变量都是节点”

节点定义的变化看着只是下标多了一个 t,但它带来的改变是本质性的。

传统空间图的节点特征,通常是整个窗口内该变量的统计量或者整条序列的嵌入,一个变量对应一个向量。全连接时空图的节点特征,是这个变量在这个时刻的观测值(或者它的嵌入表示)。节点总数从 N 变成 N×T。

这个变化有三个直接后果。第一,节点数量暴涨。N=50、T=200 的时候,节点数是 10000,稠密邻接矩阵是 1 亿个元素。这是后面所有工程问题的根源。第二,节点之间有了天然的几何结构。(n, t) 里的 n 和 t 各自有语义,可以分别加变量嵌入和时间嵌入,让模型知道自己在处理哪个传感器的哪个时刻。第三,读出(readout)方式变了。你不再是对 N 个节点做池化,而是对 N×T 个节点做池化,需要想清楚是沿时间轴先聚合再沿变量轴聚合,还是一把梭哈。

节点特征怎么构造,实践中一般是这么几步:原始标量 x[n,t] 过一个线性层升到 d 维;加上一个可学习的变量嵌入 e_n ∈ R^d,让模型区分不同传感器;加上一个时间位置嵌入 p_t ∈ R^d(正弦编码或可学习都行);如果有缺失值,再拼一个 mask 指示位。三部分相加或者拼接都试过,相加更省参数,效果差不多。

注意:变量嵌入在 N 比较大的时候(比如几百路信号)参数量会很可观,这时候可以对变量做聚类或者用共享的低秩嵌入,别硬上。

1.3 三种建图方式的取舍对照

把三种常见方案摆在一起看,取舍关系就很清楚了。

建图方式节点数边数上界能显式表达的关系主要短板
仅空间图(变量为节点)NN²瞬时变量相关性时变关系被平均掉,无时序方向
空间图 + TCN/LSTMNN²瞬时相关 + 局部时序跨变量跨时间的耦合只能隐式逼近
全连接时空图N×T(N×T)²任意 (n1,t1)→(n2,t2) 对复杂度随 N、T 平方级膨胀

从表里能直接读出一个结论:全连接时空图的表达能力是三者中最强的,代价也是最大的。所以真正的问题不是“要不要用”,而是“怎么在可接受的算力下用”。这也是第 3 节要重点解决的。

顺带说一句,这类方法不是万能的。如果你的 N 很小(比如只有 3、5 路信号),T 也不长(几十步),那全连接时空图的优势体现不出来,简单的 TCN 或者小型 Transformer 就够了。它的价值在 N 在几十到上百、T 在几百这个区间,也就是工业监测、多通道生理信号、交通流量这类典型场景。

2. 把 N×T 个观测铺成图:节点、边与特征的具体构造

这一节讲落地细节。很多人看完论文觉得思路很清晰,一动手就卡在形状对不上。我按照“张量怎么摆 → 边怎么分类 → 邻接怎么参数化”的顺序讲。

2.1 张量布局与内存账:先算清楚再动手

输入张量的标准布局是[B, T, N](batch、时间、变量),有时候是[B, N, T]。这两种布局在实现上差别很大,选错了会多出一堆 transpose,还容易出 bug。

我个人的习惯是:数据加载阶段保持[B, T, N],因为大部分数据源(CSV、DataFrame)天然是按时间行、变量列组织的;模型内部统一转成[B, N, T, D],把变量维提到前面,因为空间聚合是在变量维上做的,时间维当 batch 更自然。

内存账要提前算。节点数 V = N×T,稠密邻接矩阵占 V² × 4 字节(float32)。几个典型配置:

NTV = N×T稠密邻接内存单层 GNN 乘加量(d=64)
201002000约 16 MB约 2.6×10⁸
5020010000约 400 MB约 6.4×10⁹
10050050000约 10 GB约 1.6×10¹¹

表里第三行已经不是单卡能扛的了。而且这只是一层的邻接矩阵,注意力机制还要存 QK^T 的中间结果,实际显存占用通常是这个数的两到三倍。所以看到 N=100、T=500 这种配置,别想着直接上稠密实现,从第二节开始就得考虑降本。

还有一个容易忽略的点:batch 维度是乘上去的。上面算的是单样本。batch size 设成 32,稠密邻接就是 320 GB,直接爆。所以稠密实现基本只能 batch=1 或者用梯度累积,这也是它只能当正确性基准、不能当训练方案的原因。

2.2 空间边、时间边、对角边各自负责什么信号

全连接时空图里,“全连接”是说所有节点对之间都允许有边,但边的语义可以分成四类,理解这个分类对后面设计和调试都很重要。

第一类是空间边:同一个时间步 t、不同变量 n1 ≠ n2。这类边刻画的是瞬时相关性,和传统空间图里的边语义一致。区别在于,这里每个时间步都有一组独立的空间边,是时变的。

第二类是时间边:同一个变量 n、不同时间步 t1 ≠ t2。这类边刻画的是单条序列自身的演化,包括趋势、周期、滞后自相关。传统 TCN 用卷积核覆盖的局部窗口,在这里被表达成一组带权重的边,而且权重可以是全局的(任意距离的 t1、t2 之间都能连)。

第三类是时空交叉边:n1 ≠ n2 且 t1 ≠ t2。这是全连接时空图相对传统方案唯一新增的表达能力,也是它真正的价值所在。前面举的“电流 t=0 → 温度 t=2”就是这一类。

第四类是自环:n1 = n2 且 t1 = t2,保留节点自身信息,一般通过残差连接或者邻接矩阵对角线实现。

从邻接矩阵的分块角度看,把节点按 (n, t) 排列,A 可以切成四块:块内同 t 的是空间部分,块内同 n 的是时间部分,剩下的两块是交叉部分。实现的时候可以用四个独立的注意力模块分别算这四块,再拼起来;也可以用一个统一的注意力,让模型自己学。前者参数量小、可解释性好,后者表达能力强但容易过拟合。我一般先用前者跑 baseline。

一个实践中的观察:在很多数据集上,时空交叉边的权重分布是长尾的,少数几条边权重很高,大部分接近均匀。这意味着你可以用 top-k 稀疏化砍掉大量计算而几乎不掉点,这个后面细说。

2.3 邻接矩阵的参数化:可学习权重与稀疏化约束

邻接矩阵怎么来,有三条路。

第一条是预定义 + 固定。用皮尔逊相关系数、互信息、DTW 距离算出来,阈值化或者取 top-k,训练中不更新。优点是简单、可解释;缺点是把“时变”这个核心优势又丢掉了,等于是把传统空间图复制了 T 份。

第二条是全可学习。定义一个 N×T×N×T 的参数张量,直接当邻接矩阵用。参数量是 (NT)²,N=50、T=200 的时候是 1 亿个参数,一个数据集可能就几千个样本,必然过拟合。不推荐。

第三条是低秩参数化,也是我推荐的做法。不直接学 A,而是学两个小矩阵:节点嵌入矩阵 E ∈ R^(V×r),r 远小于 V(比如 32 或 64),然后 A = softmax(E Eᵀ / √r)。参数量从 V² 降到 V×r,N=50、T=200 的时候从 1 亿降到 32 万。这就是标准的注意力机制,只不过这里的“token”是时空节点。

低秩参数化还有个副作用是好的:它天然带了一个归纳偏置——相似的节点(在嵌入空间里距离近的)之间权重高。这个偏置在时序场景里通常是成立的,因为相似工况下的传感器行为确实相似。

稀疏化约束可以加在 softmax 之后:保留每行 top-k,其余置零;或者加一个 L1 正则项鼓励稀疏。前者更硬,后者更平滑。我一般用 top-k,k 取 8 到 16,理由是单个节点真正有信息交互的邻居通常不会太多,k 太大反而引入噪声。

3. O((NT)²) 的复杂度怎么绕:工程上的四条降本路线

这是整套方法能不能落地的关键。前面算过,N=100、T=500 的时候稠密邻接要 10 GB,这条路走不通。下面四条路线,实践中通常是组合使用。

3.1 两阶段解耦:先空间聚合,再时间聚合

最有效的一招,也是我默认采用的方案。核心思想是:不直接算 (NT)×(NT) 的全邻接,而是把它近似成“空间邻接 ⊗ 时间邻接”的因子分解形式。

具体操作分两步。第一步,把时间维当 batch,在每个时间步内部做 N 个变量之间的注意力,复杂度 O(T × N² × d)。第二步,把变量维当 batch,在每个变量内部做 T 个时间步之间的注意力,复杂度 O(N × T² × d)。总复杂度从 O(N²T²d) 降到 O(NT(N+T)d)。

拿 N=50、T=200、d=64 算一下:稠密是 50²×200²×64 ≈ 6.4×10⁹ 次乘加;解耦是 50×200×(50+200)×64 = 1.6×10⁸,降了 40 倍。显存占用也从一个 10000×10000 的矩阵降到两个 50×50 和 200×200 的矩阵,从 400 MB 降到不到 1 MB。

代价是什么?代价是丢掉了时空交叉边。两阶段解耦等价于假设“时空交叉的权重可以分解成空间权重乘时间权重”,这在数学上是一个强假设,很多真实的传播过程不满足。所以这是一个用表达能力换可行性的取舍。

我的做法是:主体用解耦结构,然后在解耦结构之上加一个轻量的交叉项补偿。补偿的方式可以是再叠一层小的全局注意力(在降采样后的节点上做),或者用一个门控机制让模型自己决定要不要引入交叉信息。这样能在保留大部分性能的同时把复杂度控制住。

3.2 时间维切片与代表节点采样

第二招针对的是 T 太大的情况。很多场景 T 能到几千(比如高频采样的振动信号),这时候别说 (NT)²,连 NT×d 的节点表示都很占地方。

切片的思路是把长序列切成若干段,每段内部建完整的图,段与段之间用粗粒度的连接。段长一般取 32 到 64,段间用步长等于段长的非重叠切分,或者 50% 重叠。重叠的好处是边界信息不会丢,坏处是计算量翻倍。

代表节点采样的思路是,不是每个时间步都保留成节点,而是先做一个时间维的降采样——比如用一维卷积把每 4 个时间步压成一个,或者用可学习的池化。这样节点数从 N×T 变成 N×(T/4),邻接矩阵小 16 倍。

这两种做法的区别在于信息损失的位置。切片是分段独立,段内信息完整,段间信息弱;降采样是全局压缩,每个节点都是多个时刻的融合,但细粒度信息没了。实证下来,如果任务依赖的是短时模式(比如冲击、突变),切片更好;如果依赖的是长周期趋势,降采样更好。

3.3 低秩分解替代显式全邻接

第三招前面提过,这里补充实现细节。不显式构造 A,而是用 A ≈ U Vᵀ,U、V ∈ R^(V×r)。前向传播时,邻居聚合写成 H' = softmax(U Vᵀ) H W,可以用矩阵乘法的结合律优化:先算 Vᵀ H(V×r 乘 r×d 再乘...),实际计算顺序是 (U (Vᵀ H)),复杂度从 O(V²d) 降到 O(Vrd)。r=32、V=10000、d=64 的时候,从 6.4×10⁹ 降到 2×10⁷。

这个技巧的适用前提是邻接矩阵本身是低秩的。经验上,多变量时序的关联结构确实有低秩倾向——几十路信号之间的有效自由度往往只有个位数到十几个,其余是冗余。所以 r 取 16 到 64 通常够用,取太大反而过拟合。

验证 r 取得合不合适有个土办法:训练完之后把学到的 A 拿去做 SVD,看奇异值的衰减曲线。如果前 r 个奇异值占了总能量的 95% 以上,说明 r 够了;如果衰减很慢,说明你的数据关联结构确实复杂,得加大 r 或者换回解耦方案。

3.4 一份显存与耗时的实测账本

把几种方案的实际开销摆出来对比,选型的时候心里有数。测试条件是单卡、batch=16、N=50、T=200、d=64、3 层。

方案峰值显存单 epoch 耗时(相对值)表达交叉边适用场景
稠密全连接(batch=1)约 12 GB100是小规模验证、正确性基准
两阶段解耦约 1.2 GB6否默认方案,大规模首选
解耦 + 交叉补偿约 1.8 GB9部分交叉边重要的任务
低秩分解(r=32)约 2.5 GB15是中等规模、需要完整表达
切片(段长 50)约 0.9 GB5段内是超长序列

这张表是我自己在几套数据上跑出来的量级,具体数字会随硬件和实现细节浮动,但相对关系基本稳定。可以看到,两阶段解耦是性价比最高的起点,先用它把 pipeline 跑通,再根据效果决定要不要加交叉补偿或者换低秩分解。

提示:不管用哪种方案,都建议先用稠密实现在一个很小的子集(比如 N=10、T=50、样本 200 条)上跑通,确认前向形状、损失能降、能过拟合这个小数据集,再切换到高效实现。直接上解耦版本,出了问题你分不清是逻辑错还是近似带来的性能损失。

4. 代码落地:一个能跑通的最小骨架

理论讲完,上代码。我用 PyTorch 写一个最小可跑的实现,包含一个用于正确性验证的稠密版本和一个用于实际训练的解耦版本。代码风格偏工程实用,不做过度封装。

4.1 模块拆分与张量形状对齐

整体分四块:输入投影、位置与变量嵌入、若干层时空图块、读出与任务头。形状流转要在动手前就定死,我习惯在代码里用注释标清楚每一步的形状,调试的时候省很多事。

输入x: [B, T, N],投影后[B, T, N, D],转置成[B, N, T, D]进入图块。图块内部,空间聚合时 reshape 成[B*T, N, D],时间聚合时 reshape 成[B*N, T, D]。最后读出时展平成[B, N*T, D]或者分别沿两个轴池化。

这个流转顺序有个坑:空间聚合后要把[B*T, N, D]恢复成[B, N, T, D],需要先 view 成[B, T, N, D]再 transpose(1,2)。直接 reshape 会打乱顺序。我第一次写的时候就在这里错了,模型能跑但效果奇差,查了两天才发现是维度顺序错了。

4.2 核心前向代码

先给一个通用的图注意力模块,作用在任意长度 L 的节点序列上,支持任意前置维度:

import torch import torch.nn as nn class GraphAttn(nn.Module): """通用一维全连接图注意力,输入形状 [..., L, D]""" def __init__(self, d_model, n_heads=4, dropout=0.1): super().__init__() assert d_model % n_heads == 0 self.h = n_heads self.dh = d_model // n_heads self.qkv = nn.Linear(d_model, d_model * 3) self.proj = nn.Linear(d_model, d_model) self.drop = nn.Dropout(dropout) def forward(self, x, mask=None): *lead, L, D = x.shape q, k, v = self.qkv(x).chunk(3, dim=-1) q = q.view(*lead, L, self.h, self.dh).transpose(-3, -2) k = k.view(*lead, L, self.h, self.dh).transpose(-3, -2) v = v.view(*lead, L, self.h, self.dh).transpose(-3, -2) att = (q @ k.transpose(-1, -2)) / (self.dh ** 0.5) if mask is not None: att = att.masked_fill(~mask.unsqueeze(-3).unsqueeze(-2), -1e4) att = self.drop(att.softmax(dim=-1)) out = (att @ v).transpose(-3, -2).reshape(*lead, L, D) return self.proj(out)

然后是解耦版时空图块,空间和时间各用一个GraphAttn,中间加残差和 LayerNorm:

class FactorizedSTBlock(nn.Module): def __init__(self, d_model, n_heads=4, dropout=0.1): super().__init__() self.spatial = GraphAttn(d_model, n_heads, dropout) self.temporal = GraphAttn(d_model, n_heads, dropout) self.norm1 = nn.LayerNorm(d_model) self.norm2 = nn.LayerNorm(d_model) self.norm3 = nn.LayerNorm(d_model) self.ffn = nn.Sequential( nn.Linear(d_model, d_model * 4), nn.GELU(), nn.Dropout(dropout), nn.Linear(d_model * 4, d_model), ) def forward(self, h, t_mask=None): # h: [B, N, T, D] B, N, T, D = h.shape # 空间聚合:每个时间步独立,变量之间互看 x = h.reshape(B * T, N, D) x = x + self.spatial(self.norm1(x)) h = x.view(B, T, N, D).transpose(1, 2) # 时间聚合:每个变量独立,时间步之间互看 x = h.reshape(B * N, T, D) m = None if t_mask is None else t_mask.repeat_interleave(N, dim=0) x = x + self.temporal(self.norm2(x), m) h = x.view(B, N, T, D) # 前馈 h = h + self.ffn(self.norm3(h)) return h

整体模型:

class FCSTModel(nn.Module): def __init__(self, n_vars, n_steps, d_model=64, n_layers=3, n_classes=2, dropout=0.1): super().__init__() self.inp = nn.Linear(1, d_model) self.var_emb = nn.Parameter(torch.zeros(n_vars, d_model)) self.step_emb = nn.Parameter(torch.zeros(n_steps, d_model)) nn.init.trunc_normal_(self.var_emb, std=0.02) nn.init.trunc_normal_(self.step_emb, std=0.02) self.blocks = nn.ModuleList( [FactorizedSTBlock(d_model, dropout=dropout) for _ in range(n_layers)] ) self.norm = nn.LayerNorm(d_model) self.head = nn.Linear(d_model * 2, n_classes) def forward(self, x, t_mask=None): # x: [B, T, N],单通道观测 B, T, N = x.shape h = self.inp(x.unsqueeze(-1)) # [B, T, N, D] h = h + self.var_emb[None, None, :, :] \ + self.step_emb[None, :T, None, :] h = h.transpose(1, 2) # [B, N, T, D] for blk in self.blocks: h = blk(h, t_mask) h = self.norm(h) pooled = torch.cat([h.mean(dim=(1, 2)), h.amax(dim=(1, 2))], dim=-1) return self.head(pooled)

读出用的是 mean 和 max 的拼接。mean 抓整体趋势,max 抓显著特征,两者互补。只用 mean 的话,异常点会被平均掉;只用 max 的话,噪声容易被放大。

如果要验证解耦实现有没有写错,用稠密版本对照:

class DenseSTAttn(nn.Module): """稠密全连接时空图,仅用于小规模正确性验证""" def __init__(self, d_model, dropout=0.1): super().__init__() self.qkv = nn.Linear(d_model, d_model * 3) self.proj = nn.Linear(d_model, d_model) self.drop = nn.Dropout(dropout) def forward(self, h): # h: [B, V, D],V = N*T B, V, D = h.shape q, k, v = self.qkv(h).chunk(3, dim=-1) att = (q @ k.transpose(-1, -2)) / (D ** 0.5) att = self.drop(att.softmax(dim=-1)) return self.proj(att @ v)

用同一份输入,稠密版应该能拟合,解耦版会略差一点,但如果差得太多(比如准确率掉十几个点),说明近似代价太大,需要考虑加交叉补偿。

4.3 损失设计与正则项

分类任务用标准的交叉熵,加 label smoothing 0.05 到 0.1,在小数据集上能稳定涨一两个点。预测任务(比如用 MSE 或者 MAE)注意在归一化空间里算损失,反归一化后再算会被不同量纲的变量主导。

正则方面,全连接时空图的参数量天然偏大,我一般会加三项:

注意力 dropout,加在 softmax 之后,比例 0.1 到 0.2。这个比常规 dropout 更有效,因为它直接作用在边权重上,防止模型过度依赖某几条边。

变量嵌入的权重衰减,单独给 var_emb 加一个比较大的 weight decay(比如 0.01),防止模型把变量嵌入学成某种“作弊”的通道标识。

边稀疏正则,对注意力矩阵加 L1,系数 1e-4 量级。作用是鼓励边权重分布变尖,配合后面的可解释性分析。

还有一个容易被忽略的:时间维的 mask。实际数据里序列长度往往是变长的,padding 部分必须 mask 掉,否则注意力会去关注全零的填充位。mask 要同时作用在空间和时间两个聚合上,空间那里其实不用(因为每个时间步独立),时间那里必须用。

5. 训练过程中的六个高频故障与定位顺序

这套模型我在几个不同数据集上复现过,踩的坑有共性。按出现频率从高到低排,附上定位思路。

5.1 损失不降但梯度正常

症状是 loss 在初始值附近小幅震荡,不收敛也不发散,梯度范数看起来正常。这个最坑,因为所有“标准检查”都过了。

排查顺序我总结成三步。第一,查归一化。多变量时序里不同变量的量纲差异极大(温度几十、电流几百、压力几千),必须每个变量独立做 z-score,而且统计量只能用训练集算。我遇到过一次因为用了全体数据的均值和方差,导致验证集上的分布偏移,模型学了个“平均预测”。

第二,查嵌入初始化。变量嵌入和时间嵌入如果初始化为全零或者太大的值,会让注意力一开始就饱和。用 trunc_normal(std=0.02) 这种小初始化,配合 warmup(前 5% 步数线性升温到目标学习率)。

第三,查图的退化。把第一层注意力矩阵打印出来看,如果每行都几乎是均匀分布(最大值减最小值小于 0.01),说明图没学起来,模型退化成简单的平均池化。这时候试试调低 softmax 温度或者去掉 top-k 约束。

5.2 过拟合来得特别早

节点数 N×T 远大于样本数的时候,这个问题几乎是必然的。设备监测这类场景,正常样本可能几千条,故障样本几十条,而节点数动辄上万。

几个有效的应对。降低层数,2 到 3 层通常足够,层数多了参数翻倍但收益很小。减小 d_model,64 是甜点,128 开始明显过拟合,除非数据量上万。加 cross-validation 的早停,用验证集上的 F1 或者 AUPRC 而不是准确率,因为类别不平衡的时候准确率没有参考价值。

还有一个比较隐蔽的招:冻结前面若干层的时空注意力,只训练后面的层和分类头。因为低层的注意力学的往往是通用的局部模式,不需要微调;高层的才和任务相关。这个技巧在小样本场景下能稳定涨点。

5.3 学出来的图“全是均匀权重”

前面提过,注意力矩阵每行都接近均匀分布。原因通常是 softmax 的归一化压力太大——节点数 V 大的时候,每个权重被压得很小,梯度也小,模型没动力去拉开差距。

解决办法有三条。一是 top-k 截断,只保留最大的 k 个再重新归一化,k 取 8 到 16。这个最直接有效。二是换用 sigmoid 加 L1 而不是 softmax,让权重不强制归一化,代价是数值稳定性差一点。三是加温度参数,softmax(att / τ),τ 从 1 开始学,模型自己决定要多尖。

我一般用第一条,简单可控,而且 top-k 之后可以顺便把稀疏结构导出来做分析。

5.4 显存 OOM 但算下来不该爆

经常是中间张量没及时释放。比如在GraphAttn里,q @ k.transpose(-1, -2)的结果是[B, H, L, L],L=10000 的时候即使 batch=1、heads=4 也是 400 MB,加上反向传播的梯度就是 1.2 GB。

两个优化。用 FlashAttention 或者 PyTorch 2.x 的scaled_dot_product_attention,内存能降好几倍。分段计算,把长序列切成几段分别算注意力再拼接,这里要注意段间的信息传递,简单切会丢边界信息。

5.5 不同随机种子结果差异巨大

这个在解耦实现上尤其明显,因为近似本身就有随机性(dropout 作用在边上)。

我的做法是固定跑 5 个种子,报告均值和中位数,而不是挑最好的那个报。如果标准差超过 3 个点,说明模型不稳定,得回头检查初始化或者加正则,别急着调超参。

5.6 验证集好但测试集崩

典型的分布偏移。时序数据的划分有讲究,必须按时间顺序切,不能随机打乱。随机打乱会导致训练集里混进了测试期之后的样本,指标虚高。按时间切之后,如果验证集和测试集差距还是很大,说明数据本身有阶段性的工况变化,这时候要考虑在线更新或者领域自适应。

一个实操建议:把训练过程中的注意力矩阵定期 dump 出来,看看不同 epoch 学到的图结构是不是稳定的。如果每一轮都在剧烈变化,说明模型没找到稳定的关联结构,这时候加正则或者降低学习率比重跑更有用。

6. 图学出来之后:可解释性与下游任务头部设计

这套方法相对其他黑盒模型的一个额外好处是,学到的边权重是可以直接看的。但“能看”和“看得对”是两回事,这块需要单独说。

6.1 注意力权重怎么读才不误读

第一个误区是把不同规模的图放在一起比权重绝对值。softmax 之后每行权重和是 1,节点数是 100 的时候平均权重是 0.01,节点数是 10000 的时候平均权重是 0.0001。跨数据集比较绝对数值毫无意义,只能比相对排序。

第二个误区是把注意力当因果。注意力权重高只说明模型在这个位置聚合了更多信息,不代表物理上存在因果。有可能只是因为那两个节点恰好数值相近,模型图省事。要验证是不是真的有关联,得做扰动实验:把那条边的权重强制置零,看预测结果变化多少。变化大的才是真的重要。

第三个误区是忽略多头之间的差异。不同 head 学到的可能是完全不同的模式,一个 head 关注局部相邻的时间步,另一个关注远距离的变量关联。直接对所有 head 求平均,会把结构糊掉。我一般会分开看每个 head 的 top-k 边,找共同出现的模式。

具体做法上,我推荐输出“边的重要性排序 + 扰动验证”而不是热力图。热力图好看但信息密度低,而且 N×T 大了之后根本看不清。给一张 top-20 边的列表,标注每条边对应的 (变量名, 时间偏移),再配合扰动实验的数值变化,这个对业务方来说有用得多。

6.2 分类、预测、异常检测的头部差异

节点表示学出来之后,怎么接下游任务,三种任务的头设计不一样。

分类任务用池化加全连接。池化我一般用 mean 和 max 拼接,前面代码里就是这么写的。如果类别不平衡严重,可以在池化后加一个注意力池化,让模型自己学哪些节点重要。另一种做法是加一个可学习的虚拟节点,让它和所有节点连边,最后用这个虚拟节点的表示做分类,效果类似 Transformer 里的 [CLS] token。

预测任务有两种接法。一种是直接预测:取最后若干个时间步对应的节点表示,展平后过几层 MLP 输出未来的值。另一种是自回归:把预测值拼回输入,滚动生成。前者简单稳定,后者适合长horizon。注意预测头的输出维度是“预测步长 × 变量数”,loss 要在归一化空间算。

异常检测最常用的是重构或者预测残差。重构的做法是加一个解码器,把节点表示映射回原始观测,用重构误差做异常分数;预测的做法是用历史预测下一步,用残差做分数。全连接时空图在这类任务上的优势是,它能把“某个变量在某个时刻偏离了它和别的变量的正常关系”这种异常捕捉到,而单变量方法只能看到数值本身的偏离。

阈值怎么定是个工程问题。我一般用训练集残差的 99 分位数,或者在验证集上扫一遍找 F1 最大的点。工业场景里更常见的是用极值理论拟合尾部分布,不过那是另一个话题了。

最后分享一个小技巧:把学到的空间边按时间轴画成变化曲线。传统方法的邻接矩阵是固定的,画不出来;这套方法每个时间步都有一组空间边,可以看到哪些变量之间的关联在什么时候增强、什么时候减弱。这个视图对理解设备工况切换特别有用,我拿它定位过几次“模型在某段时间性能下降”的原因,都是因为在那个时段变量间的关联结构变了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询