时序异常检测入门到实战(二)· 评估的陷阱:point-adjust 如何把烂模型刷成 SOTA
本文是「码海寻道」《时序异常检测入门到实战》系列的第 2 篇。上一篇结尾埋了个雷:有个叫 point-adjust 的流行评估方式,能把一个近乎随机 的模型 F1 刷到 0.9+,一度让整个领域的排行榜集体注水。这一篇就来引爆它。为什么先讲评估、再讲模型?因为评估搞错了,后面比什么都是白比------你会误以为一个花哨模型很强,其实它只是沾了指标漏洞的光。这是本系列的"随机切分翻车"时刻,请务必读完。
本篇几乎全是原理和推演,代码只有一段------但那一段能亲手把陷阱演示给你看,强烈建议跑一跑。

一个荒诞的事实:随机数生成器击败了 SOTA
先把结论摆在这儿,你可能不信:
2022 年,有研究者拿一个纯随机的异常打分器 (连数据都不看,直接掷骰子给每个点打个随机分),套上当时论文里通用的 point-adjust 评估,在多个公开 benchmark 上的 F1,超过了那几年一众精心设计的深度模型。
这不是模型太菜,是尺子坏了。用一把会自己往高处走的尺子量身高,谁量都是姚明。今天我们就把这把尺子拆开,看看它坏在哪。
二、先把正确的尺子立起来
在拆穿错的之前,先复习上一篇立的规矩------异常检测该用的三个指标(准确率已经被我们扫地出门了):
- 查准率 Precision :报警的里头,真是异常的比例。管的是"别乱叫"。
- 查全率 Recall :真实异常里,被抓到的比例。管的是"别漏报"。
- F1:两者的调和平均,一个数平衡"别乱叫"和"别漏报"。
这三个指标最朴素的算法,是**逐点(point-wise)**比对:把预测的异常点集合和真实的异常点集合摆一起,一个点一个点数------数对了几个(TP)、错报了几个(FP)、漏了几个(FN)。干净、诚实、没有歧义。
逐点 F1 本身没有任何问题。 出问题的,是有人嫌它"太严",动手改了它------改出来的那个变种,就是 point-adjust。
三、point-adjust 是怎么来的:一个善意的出发点
先说句公道话:point-adjust 的动机并不坏,甚至挺合理。
设想真实的运维场景:一次故障往往不是孤零零一个点,而是持续一段时间 (比如某服务卡了 10 分钟,这 10 分钟里的几百个采样点都被标为异常)。现在你的模型在这段故障里,只在第 3 分钟报了一次警,其余时刻没吭声。
按逐点算,这段有几百个异常点,你只命中 1 个,查全率低得惨不忍睹。可站在运维角度想------这不公平啊 :模型第 3 分钟就把警报拉响了,运维已经冲过去处理整个事件了,你何必揪着"后面几百个点你没继续报"不放?只要在一段故障里报了一次,就该算这段故障被发现了。
这个想法催生了 point-adjust:
point-adjust(点调整)规则 :对每一个真实的异常段,只要模型在段内命中了哪怕一个点 ,就把这整段的所有点,全部改判为"正确检出"。
听起来很体贴,对不对?麻烦就出在这个"哪怕一个点"上。
四、陷阱引爆:为什么它会把烂模型捧上天
我们把 point-adjust 的规则翻译成一句大白话:
在一段异常里蒙对一个点,整段几百个点就全白送给你当"命中"。
问题来了------在一长段里蒙中至少一个点,有多容易?
假设某个异常段有 L 个点 ,你的模型(哪怕纯随机)每个点有 p 的概率 会报警。那么"整段一个都没蒙中"的概率是 (1−p)^L,于是"至少蒙中一个"的概率是:
P(点亮整段) = 1 − (1−p)^L
代几个数进去感受一下,p 只取一个很小的 5%:
| 异常段长度 L | 至少蒙中一个的概率 |
|---|---|
| 10 | 40% |
| 50 | 92% |
| 100 | 99.4% |
| 200 | 99.99% |
看懂了吗?只要异常段足够长,哪怕你完全靠掷骰子,也几乎必然能蒙中其中一个点 ------然后 point-adjust 大手一挥,把整段几百个点全判给你算命中。查全率于是趋近 100%,几乎是白送的。
而查准率呢?也垮不到哪去。因为你只需要报警很少一部分点(比如 5%),落在正常区的那些误报本就不多;而落在异常段里的哪怕一个点,都会"回本"式地点亮一整段真异常。一来二去,查准也被垫高了。查全接近满分、查准也不差,F1 自然虚高到 0.9+。
一句话总结这个陷阱:point-adjust 把"检出一个点"的难度,偷偷换成了"整段全对"的奖励。异常段越长、异常占比越高,这个杠杆撬得越狠------最后连随机数都能骑着它登顶。
亲手跑一遍,眼见为实
空口无凭。下面这段完整代码,构造一批较长的异常段 ,然后让一个纯随机打分器(完全不看数据)分别在"原始逐点"和"point-adjust"两把尺子下打分。复制即跑:
python
import numpy as np
def point_adjust(pred, label):
"""标准 point-adjust:真实异常段内只要命中≥1 个点,整段全部改判为检出。"""
pred, label = pred.astype(bool).copy(), label.astype(bool)
n, i = len(label), 0
while i < n:
if label[i]:
j = i
while j < n and label[j]: # 找出这一整段连续异常 [i, j)
j += 1
if pred[i:j].any(): # 段内蒙中至少一个
pred[i:j] = True # → 整段点亮
i = j
else:
i += 1
return pred
def prf(pred, label):
"""逐点查准/查全/F1。"""
pred, label = pred.astype(bool), label.astype(bool)
tp = (pred & label).sum(); fp = (pred & ~label).sum(); fn = (~pred & label).sum()
p = tp / (tp + fp + 1e-9); r = tp / (tp + fn + 1e-9)
return p, r, 2 * p * r / (p + r + 1e-9)
rng = np.random.default_rng(0)
n = 10000
# 真实标签:10 段、每段长 200 的异常(占比 20%),互不重叠
label = np.zeros(n, dtype=bool)
for b in range(10):
start = b * 1000 + rng.integers(0, 800)
label[start:start + 200] = True
# 一个纯随机打分器:完全不看数据,掷骰子打分,报警分数最高的 5%
score = rng.random(n)
pred = score >= np.quantile(score, 0.95)
print("原始逐点 P/R/F1 = %.2f / %.2f / %.2f" % prf(pred, label))
print("point-adjust P/R/F1 = %.2f / %.2f / %.2f" % prf(point_adjust(pred, label), label))
你会看到类似这样的结果:
原始逐点 P/R/F1 = 0.20 / 0.05 / 0.08
point-adjust P/R/F1 = 0.83 / 1.00 / 0.91
同一个随机模型,同一批数据,只是换了把尺子 :F1 从惨不忍睹的 0.08 跳到堪比论文 SOTA 的 0.91 ,查全率直接拉满到 1.00。而这个模型,连数据长什么样都没看过。把上面的段长 200 调大到 400,虚高会更夸张;这就是整个领域被误导了好几年的真相。
五、那正确的评估该怎么做
拆穿了陷阱,得给你可落地的替代方案。记住下面这套,能让你在读论文、做实验时不再被漂亮数字忽悠。
铁律:永远让"随机 / 朴素基准"先过一遍你的指标
这是本系列最想让你养成的评估习惯,和预测系列"任何模型先打败 Naive"是同一条筋:
拿一个纯随机打分器、或上一篇那个移动窗口 3σ,用你即将报告的那套指标算一遍。如果它们也能拿高分,那说明你的指标坏了,而不是你的模型好了。
就像上面的实验------一旦发现随机数在 point-adjust 下能到 0.91,你立刻就该警觉:这个 0.91 不值钱。这一步几乎零成本,却能帮你避开绝大多数自欺欺人的结论。
用对指标:三个更诚实的选择
-
逐点 PR-AUC(首选做主指标) 。前面所有麻烦都出在"先卡一个阈值、再算 F1 "------阈值一动,数字就变,容易被人挑最漂亮的那个报告。PR-AUC(查准-查全曲线下面积)不依赖任何单一阈值,它衡量的是模型"给异常打高分、给正常打低分"的整体排序能力。在极不平衡场景下,它比 ROC-AUC 更能反映真实水平(ROC-AUC 会被海量正常样本稀释得虚高)。
-
事件级 / 范围级指标(贴合业务,又不失真) 。如果你确实认同"一段故障报一次就够"的业务诉求,别用会崩坏的 point-adjust,改用更严谨的范围级指标 (如 range-based precision/recall、affiliation 指标):它们同时考量"是否检出、重叠多少、报得及不及时",既奖励"报一次算发现",又不会像 PA 那样把整段无条件白送。
-
PA%K:给 point-adjust 打上补丁 。实在要用点调整,也别用"蒙中一个就点亮整段"的原始版。改用 PA%K------要求段内被命中的点**达到 K%(比如 20%)**才允许点亮整段。门槛一立,随机数就没法再靠蒙一个点薅羊毛了。
一个务实的报告姿势
别只报一个数。一篇诚实的异常检测评估,通常会同时 给出:阈值无关的 PR-AUC (看整体能力)+ 最优阈值下的逐点 F1 (看落地效果)+ 一条随机/朴素基准(当照妖镜)。三个一起看,漂亮数字才骗不了你。
小结
- 领域曾集体踩坑:一个纯随机打分器 在 point-adjust 下 F1 能到 0.9+,击败真模型------是尺子坏了,不是模型强。
- point-adjust 的规则是"段内蒙中一个点,整段全算检出 ",出发点(一段故障报一次就够)合理,但那个"一个点"是致命漏洞。
- 数学根源:段长 L、报警率 p,蒙中整段的概率是
1−(1−p)^L,段一长就趋近 1,查全率几乎白送,F1 虚高。 - 正确姿势:永远拿随机/朴素基准过一遍指标 当照妖镜;主指标用阈值无关的 PR-AUC ;要事件级就用范围级指标 或 PA%K,别用原始 point-adjust。
评估这把尺子校准好了,从下一篇起我们才能放心地上模型、真刀真枪地比。第 3 篇,正式请出全系列的及格线选手------移动窗口 3σ、箱线图、EWMA 这些统计基线,看这些"老古董"到底能打到什么程度,也给后面所有复杂模型立一个必须跨过的标杆。
思考题:PA%K 里的 K 该怎么定?把 K 定成 0% 会退化成什么(提示:就是原始 point-adjust)?把 K 定成 100% 又会苛刻到什么地步?这个 K,本质上是在"贴合运维直觉"和"防止指标注水"之间调哪一个旋钮?
校准尺子,比堆砌模型更重要。下一篇我们让统计基线登场,用几行不需要训练的老代码,定下整个系列的及格线。