🤖 第12章 异常检测
📄 一句话概括

参照集 360 个正常点(一个密簇、一个稀簇),测试集 222 个点里混进两类异常:6 个零散孤点、 6 个挤在一起的小簇。LOF 只带一个参数 k 等于 8,不用训练。 正常点的 LOF 中位数 1.099、90 分位 1.513; 零散异常 1.46 到 6.14,而紧密小簇只有 1.34 到 1.41——全部落在正常范围内。 阈值取 2:12 个异常只抓到 4 个,正常点里还误报 7 个。 AUC 在零散异常上 0.966,在紧密小簇上 0.821。

📝 本节和前面几节的关系

第 3 到第 11 章全是「有监督」:每个样本都有一个正确答案。 从这一章开始换问题——手上只有一堆「正常的东西」,要判断新来的东西「像不像它」。 没有标签,所以没有准确率可算,评价方式也得跟着换。 异常检测有三条主流思路:看局部密度(LOF)、看全局边界(单类 SVM)、 看「在这里出现得比平时多几倍」(密度比)。 这一章三节各讲一条,最后一节把它们摆在一起, 会看到它们的强项正好互补——这一点比任何单个方法都重要。

一、没有标签的题目

先看清楚这个题目的形状:训练时只有正常样本,甚至完全没有标签; 预测时给一个新点,输出「它有多不像正常的」。 没有「正确答案」,所以也没法算准确率——只能用「异常点排得够不够前」这类指标,比如 AUC。 书里把它放在「无监督学习」这一部分,但严格说它比无监督更弱一点: 单类 SVM 和密度比都要用一批「正常参照样本」。 所以更准确的叫法是「半监督」或者「一类分类」——模型只见过一类。 这一点在实践里很关键:参照样本的质量直接决定检出率,参照集脏了,后面全歪。 三条思路的分野在「拿什么当基准」。 LOF 拿「邻居」当基准,问「你比周围更孤立吗」;单类 SVM 拿「整个参照集」当基准, 问「你在边界里面还是外面」;密度比拿「参照集的密度」当基准,问「你在这里比平时常见几倍」。 基准不同,盲区就不同——这是这一章的主线。

二、LOF 是怎么算的

第一步找 k 近邻。每个点到其他所有点算距离,取最近的 k 个(实测 k 取 8)。 第二步算「可达距离」: 把「到这个邻居的距离」和「这个邻居到它自己第 k 个邻居的距离」取较大者。 这一步是为了防止「邻居离得特别近」时密度被算得虚高。 第三步:把 k 个可达距离取平均,再取倒数,得到「局部可达密度」。 密度越高,这个数越大。 第四步:LOF 等于「邻居的密度平均值」除以「自己的密度」。 这个比值的读法很直接: 约等于 1,说明你和邻居一样密;大于 1,说明你比邻居稀——你更孤立;小于 1,说明你比邻居还密。 实测正常点的中位数是 1.099、90 分位 1.513、最大 2.428——正常点自己也会有一点起伏, 所以阈值不能贴着 1 定。 「除以邻居」这四个字就是 LOF 的全部智慧,也是它的全部麻烦。 因为这是个相对量,所以它天生不怕「密度不均匀」:这份数据的两个簇密度差了几十倍, 正常点的 LOF 中位数还是 1.099。 换成绝对距离或者绝对密度,稀簇里的每个点都会被判成异常。

三、实测:零散抓得住,整簇抓不住

测试集里那 6 个「零散异常」是孤立的点,6 个「紧密小簇」挤在 (0, 11) 附近、 彼此只隔 0.12 上下。 两类的 LOF:

点的类型 LOF 值 超过正常点 90 分位的
零散异常 1.46 到 6.14 6 个里 5 个
紧密小簇 1.34 到 1.41 6 个里 0 个
零散点有两个冲得很高(3.90 和 6.14),一个只有 1.46——因为它恰好落在一个正常点附近,
局部密度并不低。
这说明 LOF 的检出率对「那个孤点离最近邻居多远」极度敏感。
紧密小簇那一行是这一节的要害:1.34 到 1.41,六个点全部低于正常点的 90 分位 1.513。
拿 LOF 大于 2 当阈值的话,这六个点一个都抓不到;
而全测试集里被这个阈值判成异常的,只有 4 个真异常和 7 个误报的正常点。
为什么?因为 LOF 的分母是「它自己邻居的密度」。
这六个点彼此挨得很近,它们互为邻居,邻居的密度和它自己的密度一样低——比值就算出来接近 1。
LOF 问的是「你比周围更孤立吗」,而这六个点是「一起孤立」,它们周围那几个邻居也一样孤立。
这不是实现问题,是定义本身的盲区:一个只看邻域的量,没有全局尺度。
量化一下:AUC 在零散异常上是 0.966,在紧密小簇上是 0.821,差了 0.145。
按分数取前 12 个,只命中 4 个(一共 12 个异常)。
这个结果不差——0.905 的总 AUC 在真实场景里算能用的——但它漏掉的是整整一类异常,不是零星几个。

四、什么时候该用 LOF

LOF 最大的优点是「不用训练」:拿到数据就能算,没有模型、没有超参数搜索,只有一个 k。 这在很多场合很实用——比如一次性排查一批日志、或者给一个陌生的数据集做初筛。 第二个优点是对密度不均匀免疫。 真实数据几乎总是多峰的(用户有小客户和大客户,传感器有白天和夜晚), 任何用「绝对距离」的方法都得先把数据标准化好,LOF 不用。 k 怎么选?k 太小,LOF 会变得很不稳(几个邻居里有一个偏离就翻倍); k 太大,局部性就丢了,逐渐退化成全局密度。 经验上从 10 到 20 起步,然后看「正常点的 LOF 分布有多宽」——如果 90 分位已经到 3, 说明 k 太小了。 它的软肋已经量出来了:看不见「整体搬走的一簇」。 工程上的绕法有两个:一是把 k 调大,让它更接近全局(代价是丢掉局部性); 二是干脆同时跑一个全局方法,两个分数都看。 下一节的单类 SVM 就是那个「全局方法」。

五、动手 Demo:局部异常因子:零散异常 1.46 到 6.14,紧密小簇只有 1.34 到 1.41

造两个簇当参照集:主簇 300 个点、标准差 0.6; 次簇 60 个点、标准差 2.0——两个簇的密度差了几十倍,这是故意的。 测试集是 210 个正常点加两类异常:6 个孤立点撒在远处,6 个点挤成一个紧密小簇放在 (0, 11)。 对测试集算 LOF(k 等于 8),看正常点的分布、两类异常的值,再用 AUC 和前 12 命中数量化。

def lof(X, k=8):
    D = dist_matrix(X, X)
    np.fill_diagonal(D, np.inf)                  # 自己不算邻居
    idx = np.argsort(D, axis=1)[:, :k]           # 每行的 k 近邻
    kdist = np.take_along_axis(D, idx, axis=1)[:, -1]
    reach = np.maximum(np.take_along_axis(D, idx, axis=1), kdist[:, None])
    lrd = 1.0 / reach.mean(axis=1)               # 局部可达密度
    return lrd[idx].mean(axis=1) / lrd           # 邻居密度除以自己的

实跑输出:

--------------------------------------------------------------------
局部异常因子:零散异常 1.46 到 6.14,紧密小簇只有 1.34 到 1.41
--------------------------------------------------------------------
  参照集                   360 个正常点:主簇 N((0,0), 0.6) 300 个,次簇 N((6,6), 2.0) 60 个
  测试集                   222 个点 = 210 正常 + 6 零散异常 + 6 紧密小簇
  零散异常                 六个孤立的点,撒在离参照集较远的地方
  紧密小簇                 六个点挤在 (0, 11) 附近,彼此相距只有 0.12 上下

  先看正常点的分布:

  统计量          LOF 值
  ---------------------
  中位数           1.099
  90 分位          1.513
  最大值           2.428
  超过 2 的个数        7

  **正常点的 LOF 大多在 1 附近——它的定义就是「邻居的局部密度平均值 除以 自己的」,密度均匀时这个比值是 1。**
  **所以 LOF 的读法是「相对邻居有多孤立」,不是「绝对有多远」。**

  再看两类异常:

  点的类型   LOF 值(六个)                       超过正常点 90 分位的
  -------------------------------------------------------------------
  零散异常   3.90、3.71、1.46、6.14、2.46、1.65   5 个
  紧密小簇   1.41、1.36、1.34、1.36、1.40、1.37   0 个

  **零散异常那一行:1.46 到 6.14,六个里有 5 个超过正常点的 90 分位 1.513。**
  **紧密小簇那一行就尴尬了:1.34 到 1.41,一个都没超过 90 分位。**
  **拿 LOF 大于 2 当阈值试试:12 个异常抓到 4 个,而正常点里有 7 个被误报。**
  **漏掉的全在紧密小簇里——那六个点的 LOF 没有一个超过 1.41。**

  **为什么?因为 LOF 的分母是「它自己邻居的密度」。**
  **这六个点彼此挨得很近,它们互为邻居,邻居的密度和它自己的密度一样低——比值就算出来接近 1。**
  **LOF 问的是「你比周围更孤立吗」,而这六个点是「一起孤立」,它们周围的邻居也一样的孤立。**
  **这不是实现问题,是 LOF 这个定义本身的盲区:它没有全局尺度。**

  用 AUC 量化一下(1 表示完全分得开,0.5 表示瞎猜):

  考哪一类           AUC   按异常分数取前 12 个,命中几个
  ------------------------------------------------------
  两类异常合起来   0.905                             4 个
  只看零散异常     0.966                                —
  只看紧密小簇     0.821                                —

  **零散异常 0.966、紧密小簇 0.821——同一个方法在两类异常上差了 0.145。**
  **前 12 个里只命中 4 个(一共 12 个异常),也是同一个原因。**
  要说明:**LOF 不需要训练,它只看测试集自己的邻域结构,所以对「密度不均匀」天然免疫——两个簇的密度差了几十倍,正常点的 LOF 中位数还是 1.099。**
  **这个优点也是它的软肋:既然只看邻域,那「整体搬走的一小簇」就看不见了。**

np.fill_diagonal(D, np.inf) 这一行不能省。 不排掉自己,每个点的最近邻就是它自己、距离为 0。 于是「第 k 个邻居的距离」就变成了「第 k 减 1 个邻居的距离」,整个 LOF 会系统性偏小, 而且偏得很隐蔽。 设成无穷大而不是 0,是为了让它永远排在最后,不会被 argsort 选进前 k。 np.take_along_axis(D, idx, axis=1) 这个写法有讲究。 直觉上会写成 D[:, idx],但那是「按行再按 idx 取」的高级索引, 结果会变成三维(序列数 乘 序列数 乘 k),形状完全错掉。 我第一版就是那么写的,症状是 LOF 返回一个二维数组、 再往下的 AUC 全是 nan——错误在很远的地方才暴露。 take_along_axis 的意思是「沿着这个轴,用同样的下标形状去取」, 这才是「每个点取它自己那 k 个邻居」的意思。 可达距离那个 np.maximum 是 LOF 论文里的关键一步,不是随手加的。 如果直接用真实距离,那么当某个邻居恰好在密集区里,它到自己的 k 邻居距离很小, 密度就会被算得极高,把整片区域的 LOF 拉低。 取「到这个邻居的距离」和「这个邻居自己的 k 距离」的较大者,相当于给密度加了个地板, 抑制这种虚假的高密度。 最后的 lrd[idx].mean(axis=1) / lrd 就是定义:邻居密度的平均 除以 自己的密度。 注意分子是「邻居的密度」,分母是「自己的密度」——搞反了的话,异常点的 LOF 会变成小于 1, 整个判读全反。 记法:LOF 大等于「我比我邻居稀」。 整个算法没有训练、没有迭代、没有随机性,所以结果是完全确定的。 代价是每次都要算全对距离矩阵,复杂度是「样本数平方」——一万个点就是 1 亿次距离, 十万个点就跑不动了。 实践里会用 KD 树或者局部敏感哈希加速,但那是工程问题,不是算法问题。 要说明:这份数据里「紧密小簇」被漏掉,是数据形态和 LOF 定义共同决定的。 如果那六个点不是紧紧挤在一起,而是各自散开,LOF 就能抓到。 所以我不能把结论写成「LOF 抓不住远处的异常」,只能说「LOF 抓不住成簇的异常」。

30 秒实验

  1. 把 k 从 8 改成 3 和 30,重跑。紧密小簇的 LOF 会变吗?正常点的分布会变宽还是变窄?
  2. 把紧密小簇那六个点的间距从 0.12 放大到 1.0(不再是紧密的一小团),重跑。 LOF 能抓到它们了吗?这说明漏检的真正原因是什么?
  3. 把主簇从 300 个点减到 30 个(让它和次簇一样稀),重跑。 正常点的 LOF 中位数会变吗?为什么这个数对「密度不均匀」不敏感?

常见误区

  1. 「LOF 大于 1 就是异常」。 实测正常点的中位数是 1.099、90 分位 1.513、最大 2.428——正常点自己就有这个起伏。 阈值必须按「正常点的分布」定,比如取 95 分位,绝不能贴着 1 定。 而且这个阈值是相对这份数据的,换一份数据就得重定。
  2. 「LOF 是密度方法,所以它能处理任意形状的分布」。 它能处理「密度不均匀」,但处理不了「异常自己成团」。 实测紧密小簇六个点的 LOF 全部落在正常范围内,一个都没超过 90 分位。 「局部」这两个字既是它的强项,也是它的定义级盲区。
  3. 「LOF 算出来的值可以直接当概率用」。 它连分数都不是,只是一个比值,没有上界、没有归一化。 想把它当概率,得另外做校准——那是第 10 章那套东西。 它能做的是排序,不是给概率。
  4. 「不需要训练,所以 LOF 一定比要训练的方法简单可靠」。 不用训练是真的,但「不用选参数」是假的:k 影响很大。 而且它每次预测都要重算整个距离矩阵, 数据一多就慢得没法上线——「免训练」换来的是「每次都要重算」。
自测三问(点开对答案) 1. 为什么 LOF 对「密度不均匀」不敏感? —— **因为它是比值,不是绝对值。** **分子是邻居的局部密度平均值,分母是自己的局部密度,两者在同一个区域里,** **区域性因素(整体稀还是整体密)在相除时抵消了。** **实测两个簇的密度差了几十倍,正常点的 LOF 中位数还是 1.099。** **换成「到第 k 个邻居的距离」这种绝对量,稀簇里的每个点都会被误判。** 2. 为什么紧密小簇的 LOF 接近 1? —— **因为那六个点互为邻居,而且它们邻居的密度和它们自己的密度一样低。** **LOF 比的是「我和我的邻居谁更稀」,而它们是「一起稀」,所以比值是 1 附近。** **要抓住这种异常,必须有一个「全局」的参照——比如整个参照集的密度,** **或者一个把参照集包起来的边界。** 3. 如果 LOF 只在正常点上算,再拿去给新点打分,可行吗? —— **不直接可行。** **LOF 的每个值都依赖「当前这批点」的邻域结构,正常点和异常点混在一起算,和分开算,** **得到的值不一样。** **工程上的做法是:把参照集和待测点拼在一起算 LOF,只取待测点那一部分的结果。** **这也是「为什么它免训练但每次都要重算」的实际含义。**

LOF 的盲区已经量清楚了:它看不见「一起孤立」的一簇。 下一节换一个完全不同的基准——不看邻居,看「整个参照集画出来的边界」。 那个边界的学法和第 8 章的 SVM 几乎一样,只是「负类」变成了一个虚拟的点。

相关笔记