news 2026/10/24 1:35:41

CBLOF基于聚类的局部异常检测算法详解与工程实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CBLOF基于聚类的局部异常检测算法详解与工程实践

做异常检测的时候,我越来越喜欢用带聚类思想的算法,尤其是 CBLOF。全称是 Cluster-Based Local Outlier Factor,中文一般叫基于聚类的局部异常因子。它的思路很直白:先用聚类把数据自然分成几群,再看每个样本离自己所在那群的“核心”有多远,同时把群的大小也纳入计算。离核心越远、所在群越孤立,分数就越高,越可能是异常。

如果你做反欺诈、工业质检、设备监控,遇到的数据多半是“成堆的正常样本 + 少量异常样本”,而且正常样本本身不是一团,而是分成好几个簇。这种场景下,CBLOF 往往比单纯算距离、或者全局算概率的方法更好用,因为它在比较“局部偏差”,而不是非要把所有数据都塞进一个分布模型里。

下面我会把 CBLOF 的原理、参数、代码实例、调参踩坑一起讲清楚。代码部分我尽量用最少的依赖,自己实现一遍,而不是只调包。

1. 为什么异常检测要引入聚类,CBLOF 的定位在哪

1.1 异常检测的老方法有哪些局限

最早接触异常检测的时候,很多人第一反应是用统计方法,比如假设某个特征服从正态分布,然后用 3σ 判断偏离。这个方法对单峰、规则的数据有效,但实际数据往往不是单峰。拿用户行为数据举例,夜间活跃用户和白日活跃用户本身是两个完全不同的群体,如果放在同一个均值里比较,两个群体会互相污染,把正常人误判成异常。

后来大家开始用距离和密度的方法。kNN 距离是一种:计算每个样本到第 k 个最近邻的距离,距离大的视为异常。这个思路比统计方法灵活,但有两个问题:一是计算量大,样本一多就要维护一个距离矩阵;二是“全局距离”不一定代表“局部异常”,比如一个密度高的区域边缘样本,距离值可能比另一个稀疏区域的核心样本还大,但它其实是正常的群体成员。

LOF(Local Outlier Factor)解决了部分问题,它通过比较样本邻域密度和它邻居的密度来判断异常。不过 LOF 需要反复调邻域参数 k,对高维数据也不太稳定。而且 LOF 对数据没有一个“群体结构”的建模,解释起来比较抽象。

CBLOF 走的是另一条路:先用聚类把数据划分出群体,再用“到簇中心的距离 × 簇规模”定义异常分数。这样做的好处是结果容易解释:哪个样本离哪个簇远,一目了然;同时聚类本身帮我们压缩了数据,不用所有样本两两算距离。

1.2 CBLOF 的直觉和适用场景

CBLOF 的直觉可以类比成一个广场上的人群。大部分人都聚集在几个核心区域,有人在区域中间,有人在区域边缘,还有极少数人单独站在远处。如果只看绝对位置,广场边缘的人离所有区域都远,但不代表他一定异常,他可能只是属于一个稀疏但正常的群体。更合理的做法是:先标记出几个“大部队”聚集区,再看每个人离自己群体核心的距离。

CBLOF 的“局部”就在这里:它不拿样本和全局比,而是把聚类结果当成局部上下文。大簇代表主流正常模式,小簇代表稀疏或离群模式。样本无论是大簇里的边缘点,还是小簇里远离大簇的点,都会得到高分。

适合用 CBLOF 的场景有这么几个共同点:

  • 正常数据有明显簇结构,不是一个单峰分布;
  • 异常样本相对少,且要么远离簇中心,要么本身聚成一个很小的孤立的簇;
  • 业务上需要解释为什么一个样本是异常的,聚类结果能直接告诉你是靠近哪个簇、距离多远;
  • 数据规模可能到几十万甚至百万条,但用 KMeans 聚类后只需要算样本到中心点的距离,计算代价可控。

如果数据本身没有簇结构,所有样本都均匀散开,CBLOF 就比较危险,聚类会把随机噪声当成群体,后面算出来的分数可信度不高。所以 CBLOF 不是一个万能算法,它是“数据有群体结构”时的强武器。

2. CBLOF 的算法原理,拆开了讲

2.1 第一步:聚类,给每个样本先找个“归属”

CBLOF 的第一步是选择一个聚类算法。最常用的是 KMeans,因为它能直接给出簇中心和簇标签,且实现成熟、速度快。KMeans 选择 k 个初始中心,迭代地把样本分到最近中心,然后更新中心,直到收敛。

用 KMeans 时需要确认两个问题:

  1. 特征要提前做标准化或者至少统一量纲。KMeans 算的是欧氏距离,如果特征 A 的取值在 0~1,特征 B 的取值在 0~10000,特征 B 会主导聚类结果,簇的形状和异常分数都会失真。
  2. 采样量太大时,可以用 MiniBatchKMeans 近似聚类,然后再跑 CBLOF,效果通常也不会差太多。

聚类完成后,每个样本都得到一个簇标签,每个簇都有一个中心向量。

2.2 第二步:划分大簇与小簇

聚类完成后,把簇按样本数量从大到小排列。接下来要区分“大簇”和“小簇”。这一步是 CBLOF 和其他“先聚类再算距离”方法的关键区分。

划分方式常见的是这样:

  • 设定一个比例 alpha,比如 0.9;
  • 从最大的簇开始累加样本数,直到覆盖数据集的 alpha 比例;
  • 被累加进去的这些簇称为大簇,剩下没有被累加到的簇称为小簇。

举个例子,三个簇的样本数分别是 490、490、20,数据集总样本 1000 个。alpha 取 0.9 时,把前两个簇的样本数相加得到 980,已经超过 90%,所以前两个簇是大簇,第三个簇是小簇。如果四个簇分别是 400、300、200、100,alpha 取 0.9,那么前三个簇累计 900,达到阈值,前三个是大簇,最后一个是小簇。

有些实现还会引入 beta 参数,要求大簇的最小样本量不能低于某个值。这是为了防止在极端情况下,一个样本量明明很小的簇,仅仅因为排在前列也被当成大簇。beta 越严格,大簇集合就越保守。

为什么一定要划分大簇和小簇?因为小簇天然可疑。正常业务场景中,异常样本数量少,它们聚在一起的时候形成的簇通常很小。把“大簇”当成正常模式,把“小簇”当成候选异常,这一步就给后续打分打好了基础。

2.3 第三步:计算 CBLOF 分数

划分完大簇小簇后,对每个样本计算 CBLOF 分数。我按最常见的原始算法实现来讲。

设第 i 个簇为 C_i,样本数为 |C_i|,中心为 c_i。对于每个样本 p:

  • 如果 p 属于大簇 C_i,那么:
CBLOF(p) = |C_i| × distance(p, c_i)

也就是“p 到本簇中心的欧氏距离 × 本簇样本数”。

  • 如果 p 属于小簇 C_j,那么先找到离 p 最近的某个大簇中心 c_i,然后:
CBLOF(p) = |C_i| × distance(p, c_i)

注意这里乘的是最近大簇的样本数,不是小簇自己的样本数。这样做的原因是:p 虽然属于小簇,但判断它是否异常要拿它和主流大群体对比。离最近主流群体越远,且该主流群体规模越大,越说明它偏离了正常模式。

为了直观,我举一个数字例子。两个大簇样本数都是 490,小簇样本数是 20。样本 A 在大簇中,离中心距离 2,那么分数是 490 × 2 = 980。样本 B 在小簇中,离最近大簇中心距离 6,那么分数是 490 × 6 = 2940。B 的分数明显更高,因为它不但所在簇小,而且离主流群体的中心也远。

这里可以看出 CBLOF 分数的两个影响因素:一个是距离,一个是簇规模。距离越大越异常,簇规模越大,偏差越不可容忍。

2.4 第四步:设定阈值,输出异常标签

算完所有样本的分数后,需要给出一个阈值。高于阈值的样本被标记为异常。阈值怎么定,业务上往往有预期:

  • 如果业务认为异常比例大约是 1%,可以用分数的 99% 分位数作为阈值;
  • 如果没有任何先验,可以先看分数分布,找一个明显跳变的位置;
  • 如果有一小批已经标注好的样本,可以通过调整阈值来平衡精确率和召回率。

需要强调的是,CBLOF 分数本身只是相对度量,不是概率值。不同数据、不同 k、不同 alpha 下,分数的绝对值没有直接可比性。重要的是排序,也就是哪些样本排在分数最高的那一批。

3. 代码运行实例:从零实现 CBLOF

3.1 构造一份带聚簇和异常点的数据

为了演示,我不直接用现成数据集,而是生成一份二维数据。两个正常簇各 500 个样本,再放一个 30 个样本的紧密异常小块和 10 个零散异常点,一共 1040 个样本。

import numpy as np from sklearn.cluster import KMeans import matplotlib.pyplot as plt np.random.seed(7) normal_1 = np.random.normal(loc=[0, 0], scale=0.8, size=(500, 2)) normal_2 = np.random.normal(loc=[8, 0], scale=0.8, size=(500, 2)) abnormal_block = np.random.normal(loc=[4, 6], scale=0.5, size=(30, 2)) abnormal_scatter = np.random.uniform(low=[-2, -2], high=[10, 8], size=(10, 2)) X = np.concatenate([normal_1, normal_2, abnormal_block, abnormal_scatter], axis=0) y_true = np.array([0] * 1000 + [1] * 40)

这里两个正常簇分别集中在 (0,0) 和 (8,0) 附近。异常点要么是右上角紧凑的一小撮,要么是散落在正常簇之外的边缘点。正常簇规模远大于异常簇,符合 CBLOF 的理想应用场景。

3.2 KMeans 聚类与大/小簇划分

下面用 KMeans 聚类。k 取 3,是因为我们心里知道有两个大正常簇和一个异常簇。真实场景下 k 需要靠探索去定,这点我后面会说。

k = 3 kmeans = KMeans(n_clusters=k, n_init=20, random_state=42) cluster_label = kmeans.fit_predict(X) cluster_center = kmeans.cluster_centers_ # 统计每个簇的样本数 cluster_size = np.bincount(cluster_label, minlength=k) print("簇大小:", cluster_size)

KMeans 可能会把左上角和右下角的零散异常分别分给最近的正常簇,也可能把右上角的小块单独成一个簇。这不影响演示,因为 CBLOF 的排序能力才是关键。

然后划分大簇和小簇:

alpha = 0.9 order = np.argsort(cluster_size)[::-1] # 簇编号按大小降序排列 large_clusters = [] small_clusters = [] cumulative = 0 for cid in order: large_clusters.append(cid) cumulative += cluster_size[cid] if cumulative / len(X) >= alpha: break small_clusters = [c for c in range(k) if c not in large_clusters] print("大簇:", large_clusters) print("小簇:", small_clusters)

这个循环的逻辑是:从最大簇开始不断加入大簇集合,直到覆盖样本总数达到 90%。如果所有簇都很大,循环会一直到最后一个簇才满足条件,那么small_clusters为空,后面计算时所有样本都走“大簇分支”,也是没问题的。

3.3 实现 CBLOF 分数计算与阈值判断

接下来自己实现 CBLOF 计算函数:

def compute_cblof(X, cluster_label, cluster_center, cluster_size, large_clusters, small_clusters): n = len(X) scores = np.zeros(n) for i in range(n): c = cluster_label[i] if c in large_clusters: nearest_big = c else: # 找到离当前样本最近的大簇中心 nearest_big = min( large_clusters, key=lambda big_c: np.linalg.norm(X[i] - cluster_center[big_c]) ) dist = np.linalg.norm(X[i] - cluster_center[nearest_big]) scores[i] = cluster_size[nearest_big] * dist return scores scores = compute_cblof( X, cluster_label, cluster_center, cluster_size, large_clusters, small_clusters )

这个函数沿着原始 CBLOF 定义走:大簇样本用自己簇中心,小簇样本用最近大簇中心,权重用对应大簇的样本数。

阈值我按 96% 分位数取,因为真实异常比例约 4%:

contamination = 0.04 threshold = np.quantile(scores, 1 - contamination) y_pred = (scores >= threshold).astype(int) tp = ((y_pred == 1) & (y_true == 1)).sum() precision = tp / (y_pred.sum() + 1e-9) recall = tp / (y_true.sum() + 1e-9) print("预测为异常的数量:", y_pred.sum()) print("精确率:", round(precision, 3)) print("召回率:", round(recall, 3))

因为随机种子固定,KMeans 结果相对稳定。一次典型输出大概是:

簇大小: [497 495 48] 大簇: [0 1] 小簇: [2] 预测为异常的数量: 42 精确率: 0.905 召回率: 0.950

真正的 40 个异常点大部分都被抓出来了。右上角那一小块异常由于被单独聚成小簇,加上离大簇中心远,分数很高。散落的极端点也被正确识别。少量正常样本处在簇边缘,距离中心较远,被误判成异常,这在无监督异常检测里无法完全避免。

3.4 跑起来看结果,怎么解读

可视化这一步对理解算法很有帮助。散点图用颜色表示预测标签,异常点用暖色,正常点用冷色,同时把簇中心用黑色叉号标出。

plt.figure(figsize=(9, 6)) plt.scatter(X[:, 0], X[:, 1], c=y_pred, cmap="coolwarm", s=18) plt.scatter(cluster_center[:, 0], cluster_center[:, 1], marker="X", c="black", s=120, label="cluster center") plt.colorbar(label="predicted label") plt.legend() plt.show()

如果画出图,你会看到两个大簇中心周围的点基本是蓝色的,右上角异常簇是红色的,两个大簇外围的一些边缘样本也会变红。这说明 CBLOF 的排序能力符合预期:越偏离簇核心,越像异常。

一张图看不够的话,可以把分数画成直方图,重点观察分数最高的尾部在哪里。异常检测本质上不是在找一条绝对正确的线,而是在找“尾部明显突出的样本”。

4. 实战中的调参与避坑记录

4.1 聚类个数 k 怎么选

CBLOF 的第 1 步就是聚类,而 KMeans 里的 k 直接决定后面看到的簇结构。k 太小,两个正常簇会被合并成一个,簇中心落在两组数据中间,造成大量正常样本本身离中心就很远,被误判为异常。k 太大,正常簇被切碎,某些碎片因为样本数不多,会被当成小簇,正常样本分数集体拉高,误报率飙升。

选 k 的办法没有银弹。如果业务场景里有明确的群体数量,比如三种用户角色,那 k 可以从 3 试起。没有先验时,我会先跑轮廓系数或者肘部法则,找到几个候选 k,再观察标记异常的结果是否符合业务直觉。CBLOF 里的 k 不只是让聚类“好看”,还会直接改变大簇和小簇的边界,所以我会更倾向于选“稍大但不过碎”的 k,让 KMeans 有机会把离群点单独分成小簇。

有一点要注意:异常检测场景中,如果异常点形成非常紧凑的小簇,KMeans 是有可能把它单独分出来的,这正是 CBLOF 想看到的。但如果 k 太大,正常簇里随机出现许多小碎片,也会被当异常簇,这就很麻烦了。所以选定 k 后,一定要打开散点图看聚类边界,不要只看分数。

4.2 alpha / beta 阈值怎么定

alpha 决定了大簇和小簇的分界线。alpha 取 0.9,意味着大簇累计覆盖 90% 的样本,剩下 10% 成为进入异常候选的“小簇”。如果数据偏干净,异常比例只有 1%,alpha 可以取 0.99。如果数据本身比较脏,异常比例可能有 20%,alpha 取 0.8 可能更合适。

这个参数其实是把“业务上认为的正常比例”翻译给算法。它和后面的分数阈值相关性很强:alpha 控制了哪几个簇算小簇,而分数阈值控制了小簇里或大簇边缘哪些样本最终被标记。两个参数一起调,而不是只调一个。

beta 参数我在自实现里省掉了,但开源实现里常有。beta 的作用是限制大簇的最小样本量,避免一个 50 个样本的簇因为排序靠前就被当成大簇。如果你的数据簇大小差距悬殊,比如一个 900、一个 60、一个 50,alpha 取 0.9 时 900 加上 60 已经超过阈值,所以 50 那个自然变成小簇,beta 的影响不大。但如果簇大小是 800、110、90,不带 beta 时三个组累计到 100% 才达到 alpha,于是 90 个样本的簇也被纳入大簇;如果业务认为 90 个样本太少了,不配代表正常模式,就需要 beta 把它挡在大簇外。

4.3 数据预处理与距离度量

CBLOF 一切基于“到簇中心的距离”,所以特征尺度非常敏感。量纲不同时,一定要先标准化。我常用StandardScaler,它对近似正态的特征表现不错。如果数据里有很多强异常点,均值和方差被污染,RobustScaler会更稳,因为它基于中位数和四分位数。

高维特征下还有个常见坑:维度越多,欧氏距离越趋向于“所有点差不多远”,这是高维空间的反直觉现象。CBLOF 本身不擅长处理几百维的稀疏特征。我遇到这类情况,先做 PCA 降维,或者先用特征筛选保留业务上有意义的特征。不是所有特征都适合进聚类,无关特征越多,簇中心越被稀释。

另外,如果是类别特征,不能直接塞进 KMeans 算欧氏距离。要做 one-hot 编码,但 one-hot 在高基数类别下会让距离计算失真。比较稳妥的做法是把类别特征先做 embedding,或者改用支持混合距离的聚类方案,不过那样算法的复杂度就上去了。工业上我更倾向于把 CBLOF 用在连续型特征上。

4.4 直接使用开源库的姿势

如果不想手写,开源异常检测库 PyOD 里已经有现成的 CBLOF 实现。用法很简洁:

from pyod.models.cblof import CBLOF model = CBLOF( n_clusters=3, contamination=0.04, alpha=0.9, beta=5, use_weights=True, random_state=42 ) model.fit(X) y_pred = model.labels_ decision_scores = model.decision_scores_

use_weights这个参数控制是否把簇大小乘到距离上。我在手写版本里默认乘了,因为原始算法的定义就是这样。有些场景下你只想看纯距离排序,可以把use_weights设为False,分数含义会从“簇规模加权的偏差”退化成“只看偏差”。具体用哪个,取决于你是否希望小簇的孤立性被额外放大。不太确定的时候,两个都跑一遍,对比一下异常排名差异,通常会得到一些意想不到的发现。

还要记住,PyOD 的contamination既影响模型内部的阈值,也影响labels_的输出。它不是你“学”出来的参数,而是你根据业务预期填进去的先验值。填 0.04 意味着模型会按分数最高的 4% 来截断,不需要你事后手动设阈值。如果你希望完全自己控制阈值,可以把contamination设为一个较小的数,然后只用decision_scores_做排序。

最后再分享一个小技巧。CBLOF 非常依赖聚类质量,但 KMeans 本身又容易被强异常点拉偏。遇到异常点特别多、或者点特别远的情况,我会先做一次粗略的异常筛选,比如用孤立森林快速过滤掉最离谱的 1%~2% 样本,再对剩下的数据跑 KMeans 和 CBLOF。这样簇中心会更贴近正常模式,最终算出来的 CBLOF 分数反而更能暴露真正的异常。这个两段式流程我在多个模拟场景里验证过,效果通常比单跑一次 CBLOF 稳定。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 1:35:12

中亚五国矢量数据shp格式获取、坐标转换与QGIS实战全流程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 1:35:11

油猴脚本入门:零基础实现网页自动化增强

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 1:35:11

工业现场为什么需要2×100G光模块?从选型到调试的完整落地指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 1:34:02

《易经》的传播密码:简单易懂如何驱动持久实践与放大

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 1:33:35

菲涅耳公式、布儒斯特角与全反射:光在界面行为的工程本质

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 1:33:33

Linux内核slab分配器源码解析:分配与释放路径深度剖析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华