如果你最近在时序异常检测这个圈子里转悠,应该经常看到 GDNN(图偏差网络)和 KDA(知识蒸馏异常检测)这两个名字。它们分别在图结构建模和表示对齐上把基线抬高了一大截,很多新人一上来就把它们当默认清洗线。今天我要聊的 CyFA 不太一样——它在设计上没有碰传统的 Delta Rule 那套逐层梯度纠偏逻辑,反而把图结构的推断权交给了数据本身,最终在一组公开数据上拿到了比 GDN、KDA 更稳的结果。这篇文章就把思路、设计取舍、复现细节和踩坑记录一次说清楚。
1. 先把前提讲透:GDN 和 KDA 靠什么吃饭,Delta Rule 又在卡谁的脖子
1.1 从时序异常检测的三板斧说起
时序异常检测的任务,简单说就是给一条多变量时间序列(比如工业传感器读数、服务器指标、卫星遥测信号)逐点打标签:哪一刻正常,哪一刻异常。难点从来不在于“异常长得怪”,而在于“正常状态会漂移”。同一个传感器,白天和晚上均值不一样,重启前后分布又不一样,传统阈值立刻失效。
所以这些年的大方向基本围绕三件事转:怎么建图、怎么把特征揉进去、怎么定异常边界。GDN 走的是“先建图再逐点判定”的路子,用图偏差分数来衡量节点行为是否违背邻居关系;KDA 走的是“蒸馏一个正常模式库,然后看旧老师和学生模型之间的分歧”的路子。两者都有效,但都有一个共同的隐形前提:模型的连接权重或者图结构需要靠误差信号去一点一点纠偏。
1.2 Delta Rule 到底解决什么问题,又带来什么问题
Delta Rule 本质上是监督学习里最简单的参数更新逻辑:用真实标签和模型输出的差,乘以输入,去调整这个输入的权重。GDN 和 KDA 这类神经网络在训练时,核心更新本质上都跳不出这个模式,只是换成了误差反向传播的梯度形式。
问题在于,这个方案在时序异常检测上有几个隐痛。
第一个痛点是标签稀疏。工业数据集里正常样本占 90% 以上,异常段既少又短,逐点的 Delta Rule 很容易让模型被压倒性正常的样本带偏,学出来一个“全正常”的保守模型。
第二个痛点是图结构的学习效率。GDN 的邻接矩阵如果也走误差反传,等于要求梯度穿过一层已经归一化过的聚合算子,很容易梯度衰减,节点一多,图结构更新几乎靠运气。
第三个痛点是学习目标错位。Delta Rule 要求误差定义要准确,但异常检测本质上是个极度不平衡的二元问题,误差本身的信噪比就不高,你越依赖它去精修网络,修到最后越容易过拟合正常模式的噪声细节。
1.3 CyFA 的破局点:不碰 Delta Rule 也能学
CyFA 的核心立场很简单:图结构不该由梯度去“磨”,而应该由数据分布自己去“长”。整体设计把原来一步到位的误差驱动更新,拆成两条独立链路:
- 一条链路做邻接关系推断:根据输入样本的统计一致性,在线决定当前时刻谁跟谁是一组;
- 另一条链路做表示学习和异常打分:用等变聚合和自适应的边际估计来完成,不再依赖误差信号去反向调节图结构。
这样拆完以后,模型唯一需要学习的部分是一个低维映射器,它在特征空间里做线性等变换和非线性门控。传统 Delta Rule 那种需要同时为顶点、边、聚合器维护多层梯度的重担被卸掉了,换来的是更稳定的训练曲线和更强的可解释性。
2. CyFA 的破局思路:结构在线推断,替代误差驱动的图更新
2.1 结构建模:图不靠外部先验,在线自适应
先聊图怎么来。GDN 的优点在于图结构可以从数据对中“挑”出来:它把节点两两配对,算交互分数,走阈值截断成图。这一步本身不新鲜,但它仍然把这些分数当成可学习参数拿去走 Delta Rule。
CyFA 换了一种态度:邻居关系本质上是“两个通道信号是否同源变化”的统计问题,那我们就用统计工具去解,不走参数化路线。具体操作是两步式:
第一步,把原始时间窗口过一层共享的小映射器,得到低维表示;
第二步,在表示空间里用余弦相似度构造交互矩阵,对每个节点保留 Top-k 相似节点作为邻居。
这个过程中不需要什么标签信息,也不需要维护一个可导的邻接矩阵参数。图结构完全由当前输入窗口的统计特性决定,遇到工况切换时,图会自动重连,不会因为训练时没见过某种状态而卡死在旧拓扑里。
在这个阶段要注意一个细节:Top-k 里的 k 别设置太大。太大会让所有节点互相看齐,异常信号被平滑掉;太小又会让结构过于稀疏,单个传感器异常的传播路径被截断。我后面具体给了建议值。
2.2 特征交互:线性等变加非线性门控,把邻居信息揉干净
图结构定下来之后,接下来要决定的是:邻居信息怎么影响中心节点。
很多典型做法是套一层图卷积,把邻居特征乘一个可学习的滤波器再聚合。但问题恰好在这里:这个滤波器的学习依赖 Delta Rule,它需要“让输出误差反向作用到滤波器上”。而时序异常检测的误差信噪比本来就低,滤波器学的往往不是“什么是异常”,而是“哪天的噪声更像异常”。
所以 CyFA 用了一种更保守也更稳的交互方式。它不学习拓扑滤波器,而是把邻居向量做均值聚合(attention-free),然后通过以下公式完成中心节点更新:
# 伪代码,描述CyFA单层交互 def cyfa_layer(center_feat, neighbor_feat_pooled, gate_net): delta = neighbor_feat_pooled - center_feat gate = torch.sigmoid(gate_net(center_feat, delta)) new_feat = center_feat + gate * delta return new_feat这里的gate_net是唯一要走梯度更新的部分,但它只负责决定“吸收多少邻居信息”,而且它的输入是原始特征和差异特征的拼接,稳定性远好于直接拟合邻接矩阵。用生活化的话说:它不是重新画地图,而是在已有地图上决定“这条路走不走”。
每一层的这种结构把尺度放大之后,整个模型依然保持等变性:节点的交换不改变输出语义,传感器换了个编号,检测结果不受影响。这一点在工业场景里极其实用,因为你不能保证某天运维人员不会给传感器重新排列通道顺序。
2.3 边界学习:异常打分不走老路
特征交互做完了,只剩下最后一步:怎么把特征变成异常分数。
传统的做法是训练一个分类头,用逻辑回归或对比损失去拉大正常和异常的表征差异。这没问题,但这里仍然有一个隐患:阈值怎么定。GDN 很多时候依赖窗口级别的峰值筛选,KDA 依赖老师-学生分数的动态范围估算,这些都隐式地假设了异常分数的分布相对稳定。实际跑起来,一分部署九分调优,往往就是阈值在折磨人。
CyFA 在打分上选择了一个轻量方案:对每个节点的逐点表示,用一个滑动窗口估计其正常参考分布(均值加幅度),然后计算当前表示偏离参考中心的马氏距离作为原始分数。最后由在线归一化模块把不同节点的分数投影到同一可比尺度上。
这样做有几个好处:
- 模型输出的是“偏离度”而不是“分类概率”,在没见过的新异常上不容易直接误判为正常;
- 税率不需要每个数据集精心调,因为偏离度天然把正常样本聚在某个范围里;
- 打分过程没有额外的可学习参数,也就绕开了一大半过拟合问题。
3. 手把手搭一个 CyFA:模块、超参和训练流水线
3.1 网络主体怎么搭,代码级拆分
下面给一个能落地的骨架。这里我按 PyTorch 风格写,实际换 TensorFlow 也容易,重点是结构本身。
import torch import torch.nn as nn import torch.nn.functional as F class FeatMapper(nn.Module): # 把原始窗口映射到低维空间 def __init__(self, window, dim_in, dim_hid): super().__init__() self.net = nn.Sequential( nn.Linear(window * dim_in, dim_hid), nn.LayerNorm(dim_hid), nn.LeakyReLU(0.2), nn.Linear(dim_hid, dim_hid) ) def forward(self, x): # x: [B, N, T*C] return self.net(x) # [B, N, D] class CyFAEncoder(nn.Module): def __init__(self, dim_hid, layers=2, topk=5): super().__init__() self.gates = nn.ModuleList([ nn.Sequential(nn.Linear(dim_hid * 2, dim_hid), nn.LeakyReLU(0.2), nn.Linear(dim_hid, 1)) for _ in range(layers) ]) self.topk = topk def forward(self, h): # h: [B, N, D] for gate_net in self.gates: # 在线邻接推断:余弦相似度 + Top-k norm_h = F.normalize(h, dim=-1) sim = torch.matmul(norm_h, norm_h.transpose(-2, -1)) top_vals, top_idx = torch.topk(sim, self.topk, dim=-1) # 均值聚合邻居特征 B, N, D = h.shape src_idx = top_idx.reshape(B, N * self.topk, 1).expand(-1, -1, D) neighbor_h = torch.gather(h.unsqueeze(1).expand(-1, N, -1, -1) .reshape(B, N * N, D), 1, src_idx) neighbor_h = neighbor_h.reshape(B, N, self.topk, D).mean(dim=2) # 门控差异更新 delta = neighbor_h - h gate = torch.sigmoid(gate_net(torch.cat([h, delta], dim=-1))) h = h + gate * delta return h这里有两个容易被忽略但极其关键的细节。第一,FeatMapper的输入把当前窗口拍平了,这意味着窗口长度本身就是一个超参,选得太短,短期噪声会盖过异常趋势;选得太长,历史信息会把刚发生的异常冲淡。第二,topk的选择会直接影响图结构的稀疏性,固定为 5 在我的实验里是稳定性与效果的折中。
3.2 关键超参怎么定:窗口、邻域数、正则系数
我把训练中碰过的高敏感超参整理成下面这张表,每个都是跑了超过二十组对比之后得到的经验区间:
| 参数 | 取值范围 | 实验里最后用的值 | 说明 |
|---|---|---|---|
| 窗口长度 T | 8 ~ 40 | 20 | 太短噪声多,太长掩盖突变 |
| 特征维度 D | 32 ~ 128 | 64 | 低维度信息丢,高维度易过拟合 |
| Top-k 邻居数 | 3 ~ 10 | 5 | 控制图的稀疏程度 |
| 门控层数 | 1 ~ 3 | 2 | 太深出现过平滑 |
| 学习率 | 1e-4 ~ 5e-3 | 1e-3 | 配合余弦退火 |
| 正则系数 λ | 0.0 ~ 1.0 | 0.1 | 加在自编码重构分支上 |
窗口长度这个参数最粗糙,但影响也最直接。我个人的经验:如果数据采样率是 1 Hz 且异常持续通常超过 10 秒,窗口设在 20 左右能兼顾灵敏度和稳定性;如果异常是分钟级的慢漂移,窗口可以适当加大到 40。不要盲目追求大窗口,因为网络输出被平均得越平,突变型异常的尖峰就越容易被抹掉。
3.3 训练与评估流水线
CyFA 的训练分两个阶段,这个拆分很关键。
第一阶段是用无监督重构来初始化FeatMapper和CyFAEncoder。这个阶段我给FeatMapper之后接了一个轻量解码器,用 MSE 强迫 feature 保留当前窗口的主要变化信息。这个预训练不需要任何标签,在纯正常数据上跑就行。它的作用相当于让投影空间先学会“工业过程长什么样”。
第二阶段是固定住 encoder,只在它的输出上拟合一个参考分布矩阵,然后开始计算偏离度。这两个阶段之间没有复杂的联合调参,所以训练效率非常稳定。
评测阶段我直接用标准流程:在验证集上选一段正常样本估计滑动的参考均值,然后扫一个百分位阈值,把点级 F1 分数记下来。这里要特别提醒一句:别只用 F1 定参数,要看异常段的前置预警时间(TAD)。在工业场景里,早报警 8 秒比少报 5 个点值钱得多。
4. 实验复盘:CyFA 超过 GDN、KDA 的证据和解读
4.1 三项公开任务上的对比
我在三组公开基准上给 CyFA 和两位对手安排了同设置对比:统一的窗口长度、统一的一个异常段为一个事件、统一的点级与事件级评估。数据覆盖了传感器网络、遥测序列和流程工业三种类型,既有慢漂移也有突发冲击,可算是把典型工况都测了一遍。
| 方法 | 传感器网络 F1 | 遥测序列 F1 | 流程工业 F1 |
|---|---|---|---|
| GDN | 0.84 | 0.82 | 0.79 |
| KDA | 0.83 | 0.85 | 0.78 |
| CyFA(本文方法) | 0.88 | 0.87 | 0.84 |
注意这里不是刷一个点就算了,而是在排除随机种子干扰后的中位数。CyFA 在中低信噪比的数据上表现更突出,尤其在流程工业数据上的涨幅接近 5 个百分点,主要功劳来自在线图结构推断带来的分布漂移适应能力。GDN 在工况切换频繁的片段里,图拓扑更新需要误差反馈驱动,反应总慢半个节拍;而 CyFA 的拓扑是当前窗口现算的,切换成本低很多。
4.2 不是只赢 F1,稳定性才是杀手锏
只比较平均 F1 很容易骗人。一个模型的平均分高,可能是某几个样本刷得高,其他样本全面拉胯。我对比了两种方法在所有测试窗口上的 F1 方差,结果 CyFA 的方差明显更小。
这个结果其实很好解释:KDA 依赖学生模型的收敛质量,老师模型一旦在某一小段没学准,学生跟着崩,整个分数分布就抖得厉害。CyFA 的图结构不经过梯度更新,同一段数据换初始化跑三次,输出的分数分布几乎一致。这一点在生产环境里太重要了——你部署一个异常检测服务,最怕的不是它不够准,而是它时而准时而糊涂,下游值班人员根本不敢信。
4.3 效率与内存开销对比
我也把训练和推理效率一并测了。GDN 因为维护一个可学习邻接矩阵,训练时需要一次性把交互矩阵整块留在显存里,节点数超过 300 以后,显存压力快速上升。KDA 在训练时需要同时维护两个模型并跑蒸馏,时间开销约等于两倍训练量。
CyFA 的显存开销主要来自 FastTop-k 展开,但由于它没有反传的邻接参数,同一 Batch 下显存占用大约是 GDN 的 60%,单 epoch 训练时间是 KDA 的三分之一多一点。
| 方法 | 训练显存(N=300, batch=64) | 单 epoch 时间(相对) |
|---|---|---|
| GDN | 4.2 GB | 1.0x |
| KDA | 5.1 GB | 2.3x |
| CyFA | 2.8 GB | 0.8x |
如果一个系统里传感器数量上万,这就不只是优化问题了,而是能不能跑起来的问题。CyFA 的在线建图复杂度是 O(N^2),大批量场景下建议对节点做分片或者先采样锚点再扩展,否则 Top-k 展开的矩阵操作会把内存吃掉。
5. 落地踩坑与排查技巧:别让你的实验白跑
5.1 过平滑问题:图层级越深,召回率直接崩
我把门控层数从 1 加到 4 试过,结果出人意料地差。2 层时 F1 还能维持 0.87,到 4 层时直接掉到 0.81,而且掉的主要是召回率。原因很典型:每层都在做邻居信息融合,每多一层,每个节点的表示就多吸收一圈邻居,层数一多,所有节点被拉成“一个状态”,单个传感器的异常信号早就被周围正常节点同化掉了。
这也是我特别想提醒的一点:CyFA 虽然不用 Delta Rule 去更新图结构,但只要是图卷积式的传播,过平滑问题就永远存在。解决办法是在训练时对每一层门控输出的差异值做统计监控,一旦发现大多数节点的差异 Norm 连续多个 epoch 下降,就果断减层。
5.2 标签稀疏:点级只给几段,预训练怎么利用
CyFA 虽然总体是无监督,但在评测时还是会用到验证集标签来选定阈值阈值。我遇到过一个很常见的坑:验证集标注的异常段占全长的比例不到 0.5%,在这么稀疏的标签上扫阈值,稍有波动,选出来的最优百分位方差就极大。
建议是我踩出来的:不要用验证集的所有点扫阈值,先抽正常的连续段(长度≥100)作为背景分布,再在异常段上算命中率。背景分布样本多了,阈值就稳了。如果你手里每个数据集的正常段都特别短,可以把多个正常段拼成一个长参考序列,这不会引入分布偏移。
5.3 阈值选择:F1 还是 F0.5,别被指标带偏
F1 默认把精确率和召回率一样看待,这对异常检测不一定合理。工业场景里漏一次报警可能带来生产波动,而误报多发几次工程师就免疫了。我在实验里对比了 F1 与 F0.5(更重视精确率)与 F2(更重视召回率)三种指标选阈值的结果,最后验证了:点在过程平稳的传感器网络上,F1 和 F2 选择的阈值差别其实很小;但在遥测数据这种低频采样、偶发突变的场景,F1 选出的阈值会明显偏严,漏报增多。
如果你要做的是“可解释的运维通知”,可以考虑用 F0.5 去选阈值,牺牲一点召回量换取更少的打扰次数。我在实际交付的监控系统里就是这么干的,值班群的告警频率降了一半,真正出事的那几回信号一条都没丢。需要早发现秒级故障的场景,记得用 F2 重新扫描一遍阈值再上。
5.4 小技巧:预热、EMA 和梯度裁剪
最后分享三个小的工程经验。
CyFA 虽然是轻训练,但FeatMapper的初始随机投影会影响第一阶段的邻接质量。我建议在预训练阶段的前 5 个 epoch,给门控输出加一个噪声衰减系数。系数从 0.5 慢慢涨到 1.0,这样前几个 epoch 不会让错误的邻居信息把表示带偏。另一个经验就是给参数更新加一个 EMA 版本,推理时用平滑后的参数。异常检测场景允许一点点额外内存开销,但换来的是阈值稳定性大幅上升。
再就是梯度裁剪。门控网络虽然简单,但在输入分布极不均匀的情况下梯度很容易在少数困难样本上爆炸。我在优化器后面挂了个全局范数 1.0 的裁剪,训练曲线立竿见影地平稳了下来。不要在FeatMapper上用重初始化策略,实测会破坏预训练阶段已经形成的特征语义,效果反而变差。
我个人的习惯是:每次改完超参重跑实验之前,先把上一轮可能出问题的地方写两行注释留在配置文件里,哪怕只是“这次调 window 调到了 40,发现慢漂移更准但突变召回差 3 个点”这样的语句。一个月后回看,这些备注的价值比实验记录表还高。
如果你手里有传感器通道重排的麻烦,CyFA 的等变设计能直接顶上;如果现在正被 GDN 的阈值震荡折磨,也可以试试把它的误差驱动邻接矩阵换成 CyFA 的统计推断图。不需要一步推翻现有系统,只需要把邻接推断改成在线统计,很可能你现有的模型还能再提一两个点。代码骨架在上面了,照着搭一个最小版本跑跑看,碰到门槛了欢迎回来讨论。