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